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