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