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}