001// Copyright 2006-2014 The Apache Software Foundation
002//
003// Licensed under the Apache License, Version 2.0 (the "License");
004// you may not use this file except in compliance with the License.
005// You may obtain a copy of the License at
006//
007// http://www.apache.org/licenses/LICENSE-2.0
008//
009// Unless required by applicable law or agreed to in writing, software
010// distributed under the License is distributed on an "AS IS" BASIS,
011// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
012// See the License for the specific language governing permissions and
013// limitations under the License.
014
015package org.apache.tapestry5.internal.services;
016
017import org.apache.tapestry5.SymbolConstants;
018import org.apache.tapestry5.func.F;
019import org.apache.tapestry5.internal.InternalConstants;
020import org.apache.tapestry5.ioc.annotations.Symbol;
021import org.apache.tapestry5.ioc.internal.util.CollectionFactory;
022import org.apache.tapestry5.ioc.internal.util.InternalUtils;
023import org.apache.tapestry5.ioc.services.ClassNameLocator;
024import org.apache.tapestry5.ioc.util.AvailableValues;
025import org.apache.tapestry5.ioc.util.UnknownValueException;
026import org.apache.tapestry5.services.ComponentClassResolver;
027import org.apache.tapestry5.services.InvalidationListener;
028import org.apache.tapestry5.services.LibraryMapping;
029import org.apache.tapestry5.services.transform.ControlledPackageType;
030import org.slf4j.Logger;
031
032import java.util.*;
033import java.util.regex.Pattern;
034
035public class ComponentClassResolverImpl implements ComponentClassResolver, InvalidationListener
036{
037    private static final String CORE_LIBRARY_PREFIX = "core/";
038
039    private static final Pattern SPLIT_PACKAGE_PATTERN = Pattern.compile("\\.");
040
041    private static final Pattern SPLIT_FOLDER_PATTERN = Pattern.compile("/");
042
043    private static final int LOGICAL_NAME_BUFFER_SIZE = 40;
044
045    private final Logger logger;
046
047    private final ClassNameLocator classNameLocator;
048
049    private final String startPageName;
050
051    // Map from library name to a list of root package names (usuallly just one).
052    private final Map<String, List<String>> libraryNameToPackageNames = CollectionFactory.newCaseInsensitiveMap();
053
054    private final Map<String, ControlledPackageType> packageNameToType = CollectionFactory.newMap();
055
056    /**
057     * Maps from a root package name to a component library name, including the empty string as the
058     * library name of the application.
059     */
060    private final Map<String, String> packageNameToLibraryName = CollectionFactory.newMap();
061
062    // Flag indicating that the maps have been cleared following an invalidation
063    // and need to be rebuilt. The flag and the four maps below are not synchronized
064    // because they are only modified inside a synchronized block. That should be strong enough ...
065    // and changes made will become "visible" at the end of the synchronized block. Because of the
066    // structure of Tapestry, there should not be any reader threads while the write thread
067    // is operating.
068
069    private volatile boolean needsRebuild = true;
070
071    private class Data
072    {
073
074        /**
075         * Logical page name to class name.
076         */
077        private final Map<String, String> pageToClassName = CollectionFactory.newCaseInsensitiveMap();
078
079        /**
080         * Component type to class name.
081         */
082        private final Map<String, String> componentToClassName = CollectionFactory.newCaseInsensitiveMap();
083
084        /**
085         * Mixing type to class name.
086         */
087        private final Map<String, String> mixinToClassName = CollectionFactory.newCaseInsensitiveMap();
088
089        /**
090         * Page class name to logical name (needed to build URLs). This one is case sensitive, since class names do always
091         * have a particular case.
092         */
093        private final Map<String, String> pageClassNameToLogicalName = CollectionFactory.newMap();
094
095        /**
096         * Used to convert a logical page name to the canonical form of the page name; this ensures that uniform case for
097         * page names is used.
098         */
099        private final Map<String, String> pageNameToCanonicalPageName = CollectionFactory.newCaseInsensitiveMap();
100
101        private void rebuild(String pathPrefix, String rootPackage)
102        {
103            fillNameToClassNameMap(pathPrefix, rootPackage, InternalConstants.PAGES_SUBPACKAGE, pageToClassName);
104            fillNameToClassNameMap(pathPrefix, rootPackage, InternalConstants.COMPONENTS_SUBPACKAGE, componentToClassName);
105            fillNameToClassNameMap(pathPrefix, rootPackage, InternalConstants.MIXINS_SUBPACKAGE, mixinToClassName);
106        }
107
108        private void fillNameToClassNameMap(String pathPrefix, String rootPackage, String subPackage,
109                                            Map<String, String> logicalNameToClassName)
110        {
111            String searchPackage = rootPackage + "." + subPackage;
112            boolean isPage = subPackage.equals(InternalConstants.PAGES_SUBPACKAGE);
113
114            Collection<String> classNames = classNameLocator.locateClassNames(searchPackage);
115
116            int startPos = searchPackage.length() + 1;
117
118            for (String name : classNames)
119            {
120                String logicalName = toLogicalName(name, pathPrefix, startPos, true);
121                String unstrippedName = toLogicalName(name, pathPrefix, startPos, false);
122
123                if (isPage)
124                {
125                    int lastSlashx = logicalName.lastIndexOf("/");
126
127                    String lastTerm = lastSlashx < 0 ? logicalName : logicalName.substring(lastSlashx + 1);
128
129                    if (lastTerm.equalsIgnoreCase("index") || lastTerm.equalsIgnoreCase(startPageName))
130                    {
131                        String reducedName = lastSlashx < 0 ? "" : logicalName.substring(0, lastSlashx);
132
133                        // Make the super-stripped name another alias to the class.
134                        // TAP5-1444: Everything else but a start page has precedence
135
136                        if (!(lastTerm.equalsIgnoreCase(startPageName) && logicalNameToClassName.containsKey(reducedName)))
137                        {
138                            logicalNameToClassName.put(reducedName, name);
139                            pageNameToCanonicalPageName.put(reducedName, logicalName);
140                        }
141                    }
142
143                    pageClassNameToLogicalName.put(name, logicalName);
144                    pageNameToCanonicalPageName.put(logicalName, logicalName);
145                    pageNameToCanonicalPageName.put(unstrippedName, logicalName);
146                }
147
148                logicalNameToClassName.put(logicalName, name);
149                logicalNameToClassName.put(unstrippedName, name);
150            }
151        }
152
153        /**
154         * Converts a fully qualified class name to a logical name
155         *
156         * @param className
157         *         fully qualified class name
158         * @param pathPrefix
159         *         prefix to be placed on the logical name (to identify the library from in which the class
160         *         lives)
161         * @param startPos
162         *         start position within the class name to extract the logical name (i.e., after the final '.' in
163         *         "rootpackage.pages.").
164         * @param stripTerms
165         * @return a short logical name in folder format ('.' replaced with '/')
166         */
167        private String toLogicalName(String className, String pathPrefix, int startPos, boolean stripTerms)
168        {
169            List<String> terms = CollectionFactory.newList();
170
171            addAll(terms, SPLIT_FOLDER_PATTERN, pathPrefix);
172
173            addAll(terms, SPLIT_PACKAGE_PATTERN, className.substring(startPos));
174
175            StringBuilder builder = new StringBuilder(LOGICAL_NAME_BUFFER_SIZE);
176            String sep = "";
177
178            String logicalName = terms.remove(terms.size() - 1);
179
180            String unstripped = logicalName;
181
182            for (String term : terms)
183            {
184                builder.append(sep);
185                builder.append(term);
186
187                sep = "/";
188
189                if (stripTerms)
190                    logicalName = stripTerm(term, logicalName);
191            }
192
193            if (logicalName.equals(""))
194                logicalName = unstripped;
195
196            builder.append(sep);
197            builder.append(logicalName);
198
199            return builder.toString();
200        }
201
202        private void addAll(List<String> terms, Pattern splitter, String input)
203        {
204            for (String term : splitter.split(input))
205            {
206                if (term.equals(""))
207                    continue;
208
209                terms.add(term);
210            }
211        }
212
213        private String stripTerm(String term, String logicalName)
214        {
215            if (isCaselessPrefix(term, logicalName))
216            {
217                logicalName = logicalName.substring(term.length());
218            }
219
220            if (isCaselessSuffix(term, logicalName))
221            {
222                logicalName = logicalName.substring(0, logicalName.length() - term.length());
223            }
224
225            return logicalName;
226        }
227
228        private boolean isCaselessPrefix(String prefix, String string)
229        {
230            return string.regionMatches(true, 0, prefix, 0, prefix.length());
231        }
232
233        private boolean isCaselessSuffix(String suffix, String string)
234        {
235            return string.regionMatches(true, string.length() - suffix.length(), suffix, 0, suffix.length());
236        }
237    }
238
239    private volatile Data data = new Data();
240
241    public ComponentClassResolverImpl(Logger logger,
242
243                                      ClassNameLocator classNameLocator,
244
245                                      @Symbol(SymbolConstants.START_PAGE_NAME)
246                                      String startPageName,
247
248                                      Collection<LibraryMapping> mappings)
249    {
250        this.logger = logger;
251        this.classNameLocator = classNameLocator;
252
253        this.startPageName = startPageName;
254
255        for (LibraryMapping mapping : mappings)
256        {
257            String libraryName = mapping.libraryName;
258
259            List<String> packages = this.libraryNameToPackageNames.get(libraryName);
260
261            if (packages == null)
262            {
263                packages = CollectionFactory.newList();
264                this.libraryNameToPackageNames.put(libraryName, packages);
265            }
266
267            packages.add(mapping.rootPackage);
268
269            // These packages, which will contain classes subject to class transformation,
270            // must be registered with the component instantiator (which is responsible
271            // for transformation).
272
273            addSubpackagesToPackageMapping(mapping.rootPackage);
274
275            packageNameToLibraryName.put(mapping.rootPackage, libraryName);
276        }
277    }
278
279    private void addSubpackagesToPackageMapping(String rootPackage)
280    {
281        for (String subpackage : InternalConstants.SUBPACKAGES)
282        {
283            packageNameToType.put(rootPackage + "." + subpackage, ControlledPackageType.COMPONENT);
284        }
285    }
286
287    public Map<String, ControlledPackageType> getControlledPackageMapping()
288    {
289        return Collections.unmodifiableMap(packageNameToType);
290    }
291
292    /**
293     * When the class loader is invalidated, clear any cached page names or component types.
294     */
295    public synchronized void objectWasInvalidated()
296    {
297        needsRebuild = true;
298    }
299
300    /**
301     * Invoked from within a withRead() block, checks to see if a rebuild is needed, and then performs the rebuild
302     * within a withWrite() block.
303     */
304    private Data getData()
305    {
306        if (!needsRebuild)
307        {
308            return data;
309        }
310
311        Data newData = new Data();
312
313        for (String prefix : libraryNameToPackageNames.keySet())
314        {
315            List<String> packages = libraryNameToPackageNames.get(prefix);
316
317            String folder = prefix + "/";
318
319            for (String packageName : packages)
320                newData.rebuild(folder, packageName);
321        }
322
323        showChanges("pages", data.pageToClassName, newData.pageToClassName);
324        showChanges("components", data.componentToClassName, newData.componentToClassName);
325        showChanges("mixins", data.mixinToClassName, newData.mixinToClassName);
326
327        needsRebuild = false;
328
329        data = newData;
330
331        return data;
332    }
333
334    private static int countUnique(Map<String, String> map)
335    {
336        return CollectionFactory.newSet(map.values()).size();
337    }
338
339    /**
340     * Log (at INFO level) the changes between the two logical-name-to-class-name maps
341     * @param title the title of the things in the maps (e.g. "pages" or "components")
342     * @param savedMap the old map
343     * @param newMap the new map
344     */
345    private void showChanges(String title, Map<String, String> savedMap, Map<String, String> newMap)
346    {
347        if (savedMap.equals(newMap) || !logger.isInfoEnabled()) // nothing to log?
348        {
349            return;
350        }
351
352        Map<String, String> core = CollectionFactory.newMap();
353        Map<String, String> nonCore = CollectionFactory.newMap();
354
355
356        int maxLength = 0;
357
358        // Pass # 1: Get all the stuff in the core library
359
360        for (String name : newMap.keySet())
361        {
362            if (name.startsWith(CORE_LIBRARY_PREFIX))
363            {
364                // Strip off the "core/" prefix.
365
366                String key = name.substring(CORE_LIBRARY_PREFIX.length());
367
368                maxLength = Math.max(maxLength, key.length());
369
370                core.put(key, newMap.get(name));
371            } else
372            {
373                maxLength = Math.max(maxLength, name.length());
374
375                nonCore.put(name, newMap.get(name));
376            }
377        }
378
379        // Merge the non-core mappings into the core mappings. Where there are conflicts on name, it
380        // means the application overrode a core page/component/mixin and that's ok ... the
381        // merged core map will reflect the application's mapping.
382
383        core.putAll(nonCore);
384
385        StringBuilder builder = new StringBuilder(2000);
386        Formatter f = new Formatter(builder);
387
388        int oldCount = countUnique(savedMap);
389        int newCount = countUnique(newMap);
390
391        f.format("Available %s (%d", title, newCount);
392
393        if (oldCount > 0 && oldCount != newCount)
394        {
395            f.format(", +%d", newCount - oldCount);
396        }
397
398        builder.append("):\n");
399
400        String formatString = "%" + maxLength + "s: %s\n";
401
402        List<String> sorted = InternalUtils.sortedKeys(core);
403
404        for (String name : sorted)
405        {
406            String className = core.get(name);
407
408            if (name.equals(""))
409                name = "(blank)";
410
411            f.format(formatString, name, className);
412        }
413
414        // log multi-line string with OS-specific line endings (TAP5-2294)
415        logger.info(builder.toString().replaceAll("\\n", System.getProperty("line.separator")));
416    }
417
418
419    public String resolvePageNameToClassName(final String pageName)
420    {
421        Data data = getData();
422
423        String result = locate(pageName, data.pageToClassName);
424
425        if (result == null)
426        {
427            throw new UnknownValueException(String.format("Unable to resolve '%s' to a page class name.",
428                    pageName), new AvailableValues("Page names", presentableNames(data.pageToClassName)));
429        }
430
431        return result;
432    }
433
434    public boolean isPageName(final String pageName)
435    {
436        return locate(pageName, getData().pageToClassName) != null;
437    }
438
439    public boolean isPage(final String pageClassName)
440    {
441        return locate(pageClassName, getData().pageClassNameToLogicalName) != null;
442    }
443
444
445    public List<String> getPageNames()
446    {
447        Data data = getData();
448
449        List<String> result = CollectionFactory.newList(data.pageClassNameToLogicalName.values());
450
451        Collections.sort(result);
452
453        return result;
454    }
455
456    public String resolveComponentTypeToClassName(final String componentType)
457    {
458        Data data = getData();
459
460        String result = locate(componentType, data.componentToClassName);
461
462        if (result == null)
463        {
464            throw new UnknownValueException(String.format("Unable to resolve '%s' to a component class name.",
465                    componentType), new AvailableValues("Component types",
466                    presentableNames(data.componentToClassName)));
467        }
468
469        return result;
470    }
471
472    Collection<String> presentableNames(Map<String, ?> map)
473    {
474        Set<String> result = CollectionFactory.newSet();
475
476        for (String name : map.keySet())
477        {
478
479            if (name.startsWith(CORE_LIBRARY_PREFIX))
480            {
481                result.add(name.substring(CORE_LIBRARY_PREFIX.length()));
482                continue;
483            }
484
485            result.add(name);
486        }
487
488        return result;
489    }
490
491    public String resolveMixinTypeToClassName(final String mixinType)
492    {
493        Data data = getData();
494
495        String result = locate(mixinType, data.mixinToClassName);
496
497        if (result == null)
498        {
499            throw new UnknownValueException(String.format("Unable to resolve '%s' to a mixin class name.",
500                    mixinType), new AvailableValues("Mixin types", presentableNames(data.mixinToClassName)));
501        }
502
503        return result;
504    }
505
506    /**
507     * Locates a class name within the provided map, given its logical name. If not found naturally, a search inside the
508     * "core" library is included.
509     *
510     * @param logicalName
511     *         name to search for
512     * @param logicalNameToClassName
513     *         mapping from logical name to class name
514     * @return the located class name or null
515     */
516    private String locate(String logicalName, Map<String, String> logicalNameToClassName)
517    {
518        String result = logicalNameToClassName.get(logicalName);
519
520        // If not found, see if it exists under the core package. In this way,
521        // anything in core is "inherited" (but overridable) by the application.
522
523        if (result != null)
524        {
525            return result;
526        }
527
528        return logicalNameToClassName.get(CORE_LIBRARY_PREFIX + logicalName);
529    }
530
531    public String resolvePageClassNameToPageName(final String pageClassName)
532    {
533        String result = getData().pageClassNameToLogicalName.get(pageClassName);
534
535        if (result == null)
536        {
537            throw new IllegalArgumentException(String.format("Unable to resolve class name %s to a logical page name.", pageClassName));
538        }
539
540        return result;
541    }
542
543    public String canonicalizePageName(final String pageName)
544    {
545        Data data = getData();
546
547        String result = locate(pageName, data.pageNameToCanonicalPageName);
548
549        if (result == null)
550        {
551            throw new UnknownValueException(String.format("Unable to resolve '%s' to a known page name.",
552                    pageName), new AvailableValues("Page names", presentableNames(data.pageNameToCanonicalPageName)));
553        }
554
555        return result;
556    }
557
558    public Map<String, String> getFolderToPackageMapping()
559    {
560        Map<String, String> result = CollectionFactory.newCaseInsensitiveMap();
561
562        for (String folder : libraryNameToPackageNames.keySet())
563        {
564            List<String> packageNames = libraryNameToPackageNames.get(folder);
565
566            String packageName = findCommonPackageNameForFolder(folder, packageNames);
567
568            result.put(folder, packageName);
569        }
570
571        return result;
572    }
573
574    static String findCommonPackageNameForFolder(String folder, List<String> packageNames)
575    {
576        String packageName = findCommonPackageName(packageNames);
577
578        if (packageName == null)
579            throw new RuntimeException(
580                    String.format(
581                            "Package names for library folder '%s' (%s) can not be reduced to a common base package (of at least one term).",
582                            folder, InternalUtils.joinSorted(packageNames)));
583        return packageName;
584    }
585
586    static String findCommonPackageName(List<String> packageNames)
587    {
588        // BTW, this is what reduce is for in Clojure ...
589
590        String commonPackageName = packageNames.get(0);
591
592        for (int i = 1; i < packageNames.size(); i++)
593        {
594            commonPackageName = findCommonPackageName(commonPackageName, packageNames.get(i));
595
596            if (commonPackageName == null)
597                break;
598        }
599
600        return commonPackageName;
601    }
602
603    static String findCommonPackageName(String commonPackageName, String packageName)
604    {
605        String[] commonExploded = explode(commonPackageName);
606        String[] exploded = explode(packageName);
607
608        int count = Math.min(commonExploded.length, exploded.length);
609
610        int commonLength = 0;
611        int commonTerms = 0;
612
613        for (int i = 0; i < count; i++)
614        {
615            if (exploded[i].equals(commonExploded[i]))
616            {
617                // Keep track of the number of shared characters (including the dot seperators)
618
619                commonLength += exploded[i].length() + (i == 0 ? 0 : 1);
620                commonTerms++;
621            } else
622            {
623                break;
624            }
625        }
626
627        if (commonTerms < 1)
628            return null;
629
630        return commonPackageName.substring(0, commonLength);
631    }
632
633    private static final Pattern DOT = Pattern.compile("\\.");
634
635    private static String[] explode(String packageName)
636    {
637        return DOT.split(packageName);
638    }
639
640    public List<String> getLibraryNames()
641    {
642        return F.flow(libraryNameToPackageNames.keySet()).remove(F.IS_BLANK).sort().toList();
643    }
644
645    public String getLibraryNameForClass(String className)
646    {
647        assert className != null;
648
649        String current = className;
650
651        while (true)
652        {
653
654            int dotx = current.lastIndexOf('.');
655
656            if (dotx < 1)
657            {
658                throw new IllegalArgumentException(String.format("Class %s is not inside any package associated with any library.",
659                        className));
660            }
661
662            current = current.substring(0, dotx);
663
664            String libraryName = packageNameToLibraryName.get(current);
665
666            if (libraryName != null)
667            {
668                return libraryName;
669            }
670        }
671    }
672}