001/* 002 * Copyright 2022-2026 Revetware LLC. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package com.soklet; 018 019import com.soklet.annotation.DELETE; 020import com.soklet.annotation.GET; 021import com.soklet.annotation.HEAD; 022import com.soklet.annotation.OPTIONS; 023import com.soklet.annotation.PATCH; 024import com.soklet.annotation.POST; 025import com.soklet.annotation.PUT; 026import com.soklet.annotation.ServerSentEventSource; 027 028import javax.annotation.concurrent.NotThreadSafe; 029import javax.annotation.processing.AbstractProcessor; 030import javax.annotation.processing.Filer; 031import javax.annotation.processing.FilerException; 032import javax.annotation.processing.Messager; 033import javax.annotation.processing.ProcessingEnvironment; 034import javax.annotation.processing.RoundEnvironment; 035import javax.lang.model.SourceVersion; 036import javax.lang.model.element.AnnotationMirror; 037import javax.lang.model.element.AnnotationValue; 038import javax.lang.model.element.Element; 039import javax.lang.model.element.ElementKind; 040import javax.lang.model.element.ExecutableElement; 041import javax.lang.model.element.Modifier; 042import javax.lang.model.element.TypeElement; 043import javax.lang.model.element.VariableElement; 044import javax.lang.model.type.TypeMirror; 045import javax.lang.model.util.Elements; 046import javax.lang.model.util.Types; 047import javax.tools.Diagnostic; 048import javax.tools.FileObject; 049import javax.tools.StandardLocation; 050import java.io.BufferedReader; 051import java.io.IOException; 052import java.io.InputStreamReader; 053import java.io.UncheckedIOException; 054import java.io.Writer; 055import java.lang.annotation.Annotation; 056import java.lang.annotation.Repeatable; 057import java.net.URI; 058import java.nio.charset.StandardCharsets; 059import java.nio.file.AtomicMoveNotSupportedException; 060import java.nio.file.Files; 061import java.nio.file.Path; 062import java.nio.file.Paths; 063import java.nio.file.StandardCopyOption; 064import java.security.MessageDigest; 065import java.security.NoSuchAlgorithmException; 066import java.util.ArrayList; 067import java.util.Arrays; 068import java.util.Base64; 069import java.util.Collections; 070import java.util.Comparator; 071import java.util.LinkedHashMap; 072import java.util.LinkedHashSet; 073import java.util.List; 074import java.util.Locale; 075import java.util.Map; 076import java.util.Set; 077import java.util.stream.Collectors; 078 079/** 080 * Soklet's standard Annotation Processor which is used to generate lookup tables of <em>Resource Method</em> definitions at compile time as well as prevent usage errors that are detectable by static analysis. 081 * <p> 082 * This Annotation Processor ensures <em>Resource Methods</em> annotated with {@link ServerSentEventSource} are declared as returning an instance of {@link HandshakeResult}. 083 * <p> 084 * Your build system should ensure this Annotation Processor is available at compile time. Follow the instructions below to make your application conformant: 085 * <p> 086 * Using {@code javac} directly: 087 * <pre>javac -parameters -processor com.soklet.SokletProcessor ...[rest of javac command elided]</pre> 088 * Using <a href="https://maven.apache.org" target="_blank">Maven</a>: 089 * <pre>{@code <plugin> 090 * <groupId>org.apache.maven.plugins</groupId> 091 * <artifactId>maven-compiler-plugin</artifactId> 092 * <version>...</version> 093 * <configuration> 094 * <release>...</release> 095 * <compilerArgs> 096 * <!-- Rest of args elided --> 097 * <arg>-parameters</arg> 098 * <arg>-processor</arg> 099 * <arg>com.soklet.SokletProcessor</arg> 100 * </compilerArgs> 101 * </configuration> 102 * </plugin>}</pre> 103 * Using <a href="https://gradle.org" target="_blank">Gradle</a>: 104 * <pre>{@code def sokletVersion = "2.0.2" // (use your actual version) 105 * 106 * dependencies { 107 * // Soklet used by your code at compile/run time 108 * implementation "com.soklet:soklet:${sokletVersion}" 109 * 110 * // Same artifact also provides the annotation processor 111 * annotationProcessor "com.soklet:soklet:${sokletVersion}" 112 * 113 * // If tests also need processing (optional) 114 * testAnnotationProcessor "com.soklet:soklet:${sokletVersion}" 115 * }}</pre> 116 * 117 * <p><strong>Incremental/IDE ("IntelliJ-safe") behavior</strong> 118 * <ul> 119 * <li>Never rebuilds the global index from only the currently-compiled sources. It always merges with the prior index.</li> 120 * <li>Only removes stale entries for top-level types compiled in the current compiler invocation (touched types).</li> 121 * <li>Skips writing the index entirely if compilation errors are present, preventing clobbering a good index.</li> 122 * <li>Writes with originating elements (best-effort) so incremental build tools can track dependencies.</li> 123 * </ul> 124 * 125 * <p><strong>Processor options</strong> 126 * <ul> 127 * <li><code>-Asoklet.cacheMode=none|sidecar|persistent</code> (default: <code>sidecar</code>)</li> 128 * <li><code>-Asoklet.cacheDir=/path</code> (used only when cacheMode=persistent; required to enable persistent)</li> 129 * <li><code>-Asoklet.pruneDeleted=true|false</code> (default: false; generally not IDE-safe)</li> 130 * <li><code>-Asoklet.debug=true|false</code> (default: false)</li> 131 * </ul> 132 * 133 * <p><strong>Important</strong>: This processor will never create a project-root <code>.soklet</code> directory by default. 134 * Persistent caching is only enabled when <code>cacheMode=persistent</code> <em>and</em> <code>soklet.cacheDir</code> is set. 135 * 136 * @author <a href="https://www.revetkn.com">Mark Allen</a> 137 */ 138@NotThreadSafe 139public final class SokletProcessor extends AbstractProcessor { 140 // ---- Options ------------------------------------------------------------ 141 142 private static final String PROCESSOR_OPTION_CACHE_MODE = "soklet.cacheMode"; 143 private static final String PROCESSOR_OPTION_CACHE_DIR = "soklet.cacheDir"; 144 private static final String PROCESSOR_OPTION_PRUNE_DELETED = "soklet.pruneDeleted"; 145 private static final String PROCESSOR_OPTION_DEBUG = "soklet.debug"; 146 147 private static final String PERSISTENT_CACHE_INDEX_DIR = "resource-methods"; 148 149 // ---- Index paths --------------------------------------------------------- 150 151 static final String RESOURCE_METHOD_LOOKUP_TABLE_PATH = "META-INF/soklet/resource-method-lookup-table"; 152 private static final String OUTPUT_ROOT_MARKER_PATH = "META-INF/soklet/.soklet-output-root"; 153 154 private static final String SIDE_CAR_DIR_NAME = "soklet"; 155 private static final String SIDE_CAR_INDEX_FILENAME = "resource-method-lookup-table"; 156 157 // ---- JSR-269 services ---------------------------------------------------- 158 159 private Types types; 160 private Elements elements; 161 private Messager messager; 162 private Filer filer; 163 164 private boolean debugEnabled; 165 private boolean pruneDeletedEnabled; 166 private CacheMode cacheMode; 167 168 // Cached mirrors resolved in init() 169 private TypeMirror handshakeResultType; // com.soklet.HandshakeResult 170 private TypeElement pathParameterElement; // com.soklet.annotation.PathParameter 171 172 // Collected during this compilation invocation 173 private final List<ResourceMethodDeclaration> collected = new ArrayList<>(); 174 private final Set<String> touchedTopLevelBinaries = new LinkedHashSet<>(); 175 176 // ---- Supported annotations ---------------------------------------------- 177 178 private static final List<Class<? extends Annotation>> HTTP_AND_SSE_ANNOTATIONS = List.of( 179 GET.class, POST.class, PUT.class, PATCH.class, DELETE.class, HEAD.class, OPTIONS.class, 180 ServerSentEventSource.class 181 ); 182 183 // ---- Cache modes --------------------------------------------------------- 184 185 private enum CacheMode { 186 NONE, // Only CLASS_OUTPUT index. No sidecar/persistent. Lowest clutter, lowest resiliency. 187 SIDECAR, // CLASS_OUTPUT + sidecar (under the class output parent directory). Default. 188 PERSISTENT // CLASS_OUTPUT + sidecar + persistent (under soklet.cacheDir). Requires soklet.cacheDir. 189 } 190 191 @Override 192 public synchronized void init(ProcessingEnvironment processingEnv) { 193 super.init(processingEnv); 194 this.types = processingEnv.getTypeUtils(); 195 this.elements = processingEnv.getElementUtils(); 196 this.messager = processingEnv.getMessager(); 197 this.filer = processingEnv.getFiler(); 198 199 this.debugEnabled = parseBooleanishOption(processingEnv.getOptions().get(PROCESSOR_OPTION_DEBUG)); 200 this.pruneDeletedEnabled = parseBooleanishOption(processingEnv.getOptions().get(PROCESSOR_OPTION_PRUNE_DELETED)); 201 this.cacheMode = parseCacheMode(processingEnv.getOptions().get(PROCESSOR_OPTION_CACHE_MODE)); 202 203 TypeElement hr = elements.getTypeElement("com.soklet.HandshakeResult"); 204 this.handshakeResultType = (hr == null ? null : hr.asType()); 205 this.pathParameterElement = elements.getTypeElement("com.soklet.annotation.PathParameter"); 206 207 // If persistent mode was requested but cacheDir isn't configured, downgrade to SIDECAR. 208 if (this.cacheMode == CacheMode.PERSISTENT && persistentCacheRoot() == null) { 209 debug("SokletProcessor: cacheMode=persistent requested but %s not set/invalid; falling back to sidecar.", 210 PROCESSOR_OPTION_CACHE_DIR); 211 this.cacheMode = CacheMode.SIDECAR; 212 } 213 } 214 215 @Override 216 public Set<String> getSupportedAnnotationTypes() { 217 Set<String> out = new LinkedHashSet<>(); 218 for (Class<? extends Annotation> c : HTTP_AND_SSE_ANNOTATIONS) { 219 out.add(c.getCanonicalName()); 220 Class<? extends Annotation> container = findRepeatableContainer(c); 221 if (container != null) out.add(container.getCanonicalName()); 222 } 223 return out; 224 } 225 226 @Override 227 public SourceVersion getSupportedSourceVersion() { 228 return SourceVersion.latestSupported(); 229 } 230 231 @Override 232 public Set<String> getSupportedOptions() { 233 return new LinkedHashSet<>(List.of( 234 PROCESSOR_OPTION_CACHE_MODE, 235 PROCESSOR_OPTION_CACHE_DIR, 236 PROCESSOR_OPTION_PRUNE_DELETED, 237 PROCESSOR_OPTION_DEBUG 238 )); 239 } 240 241 @Override 242 public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { 243 // Track top-level types being compiled in this invocation. 244 for (Element root : roundEnv.getRootElements()) { 245 if (root instanceof TypeElement te) { 246 String bin = elements.getBinaryName(te).toString(); 247 touchedTopLevelBinaries.add(bin); 248 } 249 } 250 251 // SSE-specific return type check 252 enforceSseReturnTypes(roundEnv); 253 254 // Collect + validate 255 collect(roundEnv, HttpMethod.GET, GET.class, false); 256 collect(roundEnv, HttpMethod.POST, POST.class, false); 257 collect(roundEnv, HttpMethod.PUT, PUT.class, false); 258 collect(roundEnv, HttpMethod.PATCH, PATCH.class, false); 259 collect(roundEnv, HttpMethod.DELETE, DELETE.class, false); 260 collect(roundEnv, HttpMethod.HEAD, HEAD.class, false); 261 collect(roundEnv, HttpMethod.OPTIONS, OPTIONS.class, false); 262 collect(roundEnv, HttpMethod.GET, ServerSentEventSource.class, true); // SSE as GET + flag 263 264 if (roundEnv.processingOver()) { 265 // Critical: don't overwrite a good index with a partial/failed compile. 266 if (roundEnv.errorRaised()) { 267 debug("SokletProcessor: compilation has errors; skipping index write to avoid clobbering."); 268 return false; 269 } 270 mergeAndWriteIndex(collected, touchedTopLevelBinaries); 271 } 272 273 return false; 274 } 275 276 /** 277 * Collects and validates each annotated method occurrence (repeatable-aware, without reflection). 278 */ 279 private void collect(RoundEnvironment roundEnv, 280 HttpMethod httpMethod, 281 Class<? extends Annotation> baseAnnotation, 282 boolean serverSentEventSource) { 283 284 TypeElement base = elements.getTypeElement(baseAnnotation.getCanonicalName()); 285 Class<? extends Annotation> containerClass = findRepeatableContainer(baseAnnotation); 286 TypeElement container = containerClass == null ? null : elements.getTypeElement(containerClass.getCanonicalName()); 287 288 Set<Element> candidates = new LinkedHashSet<>(); 289 if (base != null) candidates.addAll(roundEnv.getElementsAnnotatedWith(base)); 290 if (container != null) candidates.addAll(roundEnv.getElementsAnnotatedWith(container)); 291 292 for (Element e : candidates) { 293 if (e.getKind() != ElementKind.METHOD) { 294 error(e, "Soklet: @%s can only be applied to methods.", baseAnnotation.getSimpleName()); 295 continue; 296 } 297 298 ExecutableElement method = (ExecutableElement) e; 299 TypeElement owner = (TypeElement) method.getEnclosingElement(); 300 301 boolean isPublic = method.getModifiers().contains(Modifier.PUBLIC); 302 boolean isStatic = method.getModifiers().contains(Modifier.STATIC); 303 304 if (isStatic) error(method, "Soklet: Resource Method must not be static"); 305 if (!isPublic) error(method, "Soklet: Resource Method must be public"); 306 307 // Extract each occurrence as an AnnotationMirror (handles repeatable containers) 308 List<AnnotationMirror> occurrences = extractOccurrences(method, base, container); 309 310 for (AnnotationMirror annMirror : occurrences) { 311 String rawPath = readAnnotationStringMember(annMirror, "value"); 312 if (rawPath == null || rawPath.isBlank()) { 313 error(method, "Soklet: @%s must have a non-empty path value", baseAnnotation.getSimpleName()); 314 continue; 315 } 316 317 String path = normalizePath(rawPath); 318 319 ValidationResult vr = validatePathTemplate(method, path); 320 if (!vr.ok) continue; 321 322 ParamBindings pb = readPathParameterBindings(method); 323 324 // a) placeholders must be bound 325 for (String placeholder : vr.placeholders) { 326 if (!pb.paramNames.contains(placeholder)) { 327 String shown = vr.original.getOrDefault(placeholder, placeholder); 328 error(method, "Resource Method path parameter {" + shown + "} not bound to a @PathParameter argument"); 329 } 330 } 331 332 // b) annotated params must exist in template 333 for (String annotated : pb.paramNames) { 334 if (!vr.placeholders.contains(annotated)) { 335 error(method, "No placeholder {" + annotated + "} present in resource path declaration"); 336 } 337 } 338 339 // Only collect if this method is otherwise valid 340 if (!pb.hadError && vr.ok && isPublic && !isStatic) { 341 String className = elements.getBinaryName(owner).toString(); 342 String methodName = method.getSimpleName().toString(); 343 String[] paramTypes = method.getParameters().stream() 344 .map(p -> jvmTypeName(p.asType())) 345 .toArray(String[]::new); 346 347 collected.add(new ResourceMethodDeclaration( 348 httpMethod, path, className, methodName, paramTypes, serverSentEventSource 349 )); 350 } 351 } 352 } 353 } 354 355 private List<AnnotationMirror> extractOccurrences(ExecutableElement method, TypeElement base, TypeElement container) { 356 List<AnnotationMirror> out = new ArrayList<>(); 357 358 for (AnnotationMirror am : method.getAnnotationMirrors()) { 359 if (base != null && isAnnotationType(am, base)) { 360 out.add(am); 361 } else if (container != null && isAnnotationType(am, container)) { 362 Object v = readAnnotationMemberValue(am, "value"); 363 if (v instanceof List<?> list) { 364 for (Object o : list) { 365 if (o instanceof AnnotationValue av) { 366 Object inner = av.getValue(); 367 if (inner instanceof AnnotationMirror innerAm) { 368 out.add(innerAm); 369 } 370 } 371 } 372 } 373 } 374 } 375 376 return out; 377 } 378 379 // --- Helpers for parameter annotations ------------------------------------ 380 381 private static final class ParamBindings { 382 final Set<String> paramNames; 383 final boolean hadError; 384 385 ParamBindings(Set<String> names, boolean hadError) { 386 this.paramNames = names; 387 this.hadError = hadError; 388 } 389 } 390 391 private ParamBindings readPathParameterBindings(ExecutableElement method) { 392 boolean hadError = false; 393 Set<String> names = new LinkedHashSet<>(); 394 if (pathParameterElement == null) return new ParamBindings(names, false); 395 396 for (VariableElement p : method.getParameters()) { 397 for (AnnotationMirror am : p.getAnnotationMirrors()) { 398 if (isAnnotationType(am, pathParameterElement)) { 399 // 1) try explicit annotation member 400 String name = readAnnotationStringMember(am, "name"); 401 // 2) default to the parameter's source name if missing/blank 402 if (name == null || name.isBlank()) { 403 name = p.getSimpleName().toString(); 404 } 405 if (name != null && !name.isBlank()) { 406 names.add(name); 407 } 408 } 409 } 410 } 411 412 return new ParamBindings(names, hadError); 413 } 414 415 private static boolean isAnnotationType(AnnotationMirror am, TypeElement type) { 416 return am.getAnnotationType().asElement().equals(type); 417 } 418 419 private static Object readAnnotationMemberValue(AnnotationMirror am, String member) { 420 for (Map.Entry<? extends ExecutableElement, ? extends AnnotationValue> e : am.getElementValues().entrySet()) { 421 if (e.getKey().getSimpleName().contentEquals(member)) { 422 return e.getValue().getValue(); 423 } 424 } 425 return null; 426 } 427 428 private static String readAnnotationStringMember(AnnotationMirror am, String member) { 429 Object v = readAnnotationMemberValue(am, member); 430 return (v == null) ? null : v.toString(); 431 } 432 433 // --- Path parsing/validation ---------------------------------------------- 434 435 private static final class ValidationResult { 436 final boolean ok; 437 final Set<String> placeholders; // normalized names (no trailing '*') 438 final Map<String, String> original; // normalized -> original token 439 440 ValidationResult(boolean ok, Set<String> placeholders, Map<String, String> original) { 441 this.ok = ok; 442 this.placeholders = placeholders; 443 this.original = original; 444 } 445 } 446 447 /** 448 * Validates braces and duplicate placeholders (treating {name*} as a greedy/varargs placeholder whose 449 * logical name is "name"). Duplicate detection is done on the normalized name (without trailing '*'). 450 */ 451 private ValidationResult validatePathTemplate(Element reportOn, String path) { 452 if (path == null || path.isEmpty()) { 453 return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap()); 454 } 455 456 Set<String> names = new LinkedHashSet<>(); 457 Map<String, String> originalTokens = new LinkedHashMap<>(); 458 459 int i = 0; 460 while (i < path.length()) { 461 char c = path.charAt(i); 462 if (c == '{') { 463 int close = path.indexOf('}', i + 1); 464 if (close < 0) { 465 error(reportOn, "Soklet: Malformed resource path declaration (unbalanced braces)"); 466 return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap()); 467 } 468 469 String token = path.substring(i + 1, close); // e.g., "id", "cssPath*" 470 if (token.isEmpty()) { 471 error(reportOn, "Soklet: Malformed resource path declaration (unbalanced braces)"); 472 return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap()); 473 } 474 475 String normalized = normalizePlaceholder(token); 476 if (normalized.isEmpty()) { 477 error(reportOn, "Soklet: Malformed resource path declaration (unbalanced braces)"); 478 return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap()); 479 } 480 481 if (!names.add(normalized)) { 482 error(reportOn, "Soklet: Duplicate @PathParameter name: " + normalized); 483 } 484 originalTokens.putIfAbsent(normalized, token); 485 486 i = close + 1; 487 } else if (c == '}') { 488 error(reportOn, "Soklet: Malformed resource path declaration (unbalanced braces)"); 489 return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap()); 490 } else { 491 i++; 492 } 493 } 494 495 return new ValidationResult(true, names, originalTokens); 496 } 497 498 private static String normalizePlaceholder(String token) { 499 if (token.endsWith("*")) return token.substring(0, token.length() - 1); 500 return token; 501 } 502 503 // --- Existing utilities ---------------------------------------------------- 504 505 private static String normalizePath(String p) { 506 if (p == null || p.isEmpty()) return "/"; 507 if (p.charAt(0) != '/') return "/" + p; 508 return p; 509 } 510 511 private static Class<? extends Annotation> findRepeatableContainer(Class<? extends Annotation> base) { 512 Repeatable repeatable = base.getAnnotation(Repeatable.class); 513 return (repeatable == null) ? null : repeatable.value(); 514 } 515 516 private String jvmTypeName(TypeMirror t) { 517 switch (t.getKind()) { 518 case BOOLEAN: 519 return "boolean"; 520 case BYTE: 521 return "byte"; 522 case SHORT: 523 return "short"; 524 case CHAR: 525 return "char"; 526 case INT: 527 return "int"; 528 case LONG: 529 return "long"; 530 case FLOAT: 531 return "float"; 532 case DOUBLE: 533 return "double"; 534 case VOID: 535 return "void"; 536 case ARRAY: 537 return "[" + jvmTypeDescriptor(((javax.lang.model.type.ArrayType) t).getComponentType()); 538 case DECLARED: 539 default: 540 TypeMirror erasure = processingEnv.getTypeUtils().erasure(t); 541 Element el = processingEnv.getTypeUtils().asElement(erasure); 542 if (el instanceof TypeElement te) { 543 return processingEnv.getElementUtils().getBinaryName(te).toString(); 544 } 545 return erasure.toString(); 546 } 547 } 548 549 private String jvmTypeDescriptor(TypeMirror t) { 550 switch (t.getKind()) { 551 case BOOLEAN: 552 return "Z"; 553 case BYTE: 554 return "B"; 555 case SHORT: 556 return "S"; 557 case CHAR: 558 return "C"; 559 case INT: 560 return "I"; 561 case LONG: 562 return "J"; 563 case FLOAT: 564 return "F"; 565 case DOUBLE: 566 return "D"; 567 case ARRAY: 568 return "[" + jvmTypeDescriptor(((javax.lang.model.type.ArrayType) t).getComponentType()); 569 case DECLARED: 570 default: 571 TypeMirror erasure = processingEnv.getTypeUtils().erasure(t); 572 Element el = processingEnv.getTypeUtils().asElement(erasure); 573 if (el instanceof TypeElement te) { 574 String bin = processingEnv.getElementUtils().getBinaryName(te).toString(); 575 return "L" + bin + ";"; 576 } 577 return "Ljava/lang/Object;"; 578 } 579 } 580 581 // ---- SSE return-type validation ------------------------------------------ 582 583 private void enforceSseReturnTypes(RoundEnvironment roundEnv) { 584 if (handshakeResultType == null) return; 585 586 TypeElement sseAnn = elements.getTypeElement(ServerSentEventSource.class.getCanonicalName()); 587 if (sseAnn == null) return; 588 589 for (Element e : roundEnv.getElementsAnnotatedWith(sseAnn)) { 590 if (e.getKind() != ElementKind.METHOD) { 591 error(e, "@%s can only be applied to methods.", ServerSentEventSource.class.getSimpleName()); 592 continue; 593 } 594 ExecutableElement method = (ExecutableElement) e; 595 TypeMirror returnType = method.getReturnType(); 596 boolean assignable = types.isAssignable(returnType, handshakeResultType); 597 if (!assignable) { 598 error(e, 599 "Soklet: Resource Methods annotated with @%s must specify a return type of %s (found: %s).", 600 ServerSentEventSource.class.getSimpleName(), "HandshakeResult", prettyType(returnType)); 601 } 602 } 603 } 604 605 private static String prettyType(TypeMirror t) { 606 return (t == null ? "null" : t.toString()); 607 } 608 609 // ---- Index read/merge/write ---------------------------------------------- 610 611 private void mergeAndWriteIndex(List<ResourceMethodDeclaration> newlyCollected, 612 Set<String> touchedTopLevelBinaries) { 613 614 Path classOutputRoot = findClassOutputRoot(); 615 Path classOutputIndexPath = (classOutputRoot == null ? null : classOutputRoot.resolve(RESOURCE_METHOD_LOOKUP_TABLE_PATH)); 616 617 Path sideCarIndexPath = (cacheMode == CacheMode.NONE ? null : sideCarIndexPath(classOutputRoot)); 618 Path persistentIndexPath = (cacheMode == CacheMode.PERSISTENT ? persistentIndexPath(classOutputRoot) : null); 619 620 debug("SokletProcessor: cacheMode=%s", cacheMode); 621 debug("SokletProcessor: classOutputRoot=%s", classOutputRoot); 622 debug("SokletProcessor: classOutputIndexPath=%s", classOutputIndexPath); 623 debug("SokletProcessor: sidecarIndexPath=%s", sideCarIndexPath); 624 debug("SokletProcessor: persistentIndexPath=%s", persistentIndexPath); 625 debug("SokletProcessor: touchedTopLevels=%s", touchedTopLevelBinaries); 626 627 // Always merge from ALL enabled sources. Never "fallback only if empty". 628 Map<String, ResourceMethodDeclaration> merged = new LinkedHashMap<>(); 629 630 // Oldest/most durable first 631 if (persistentIndexPath != null) readIndexFromPath(persistentIndexPath, merged); 632 if (sideCarIndexPath != null) readIndexFromPath(sideCarIndexPath, merged); 633 634 // Then current output dir (direct file access, if possible) 635 if (classOutputIndexPath != null) readIndexFromPath(classOutputIndexPath, merged); 636 637 // Then via filer (often works even if direct file paths don't) 638 readIndexFromLocation(StandardLocation.CLASS_OUTPUT, merged); 639 640 debug("SokletProcessor: mergedExistingIndexSize=%d", merged.size()); 641 642 // Remove stale entries for classes being recompiled now (top-level + nested) 643 removeTouchedEntries(merged, touchedTopLevelBinaries); 644 debug("SokletProcessor: afterRemovingTouched=%d", merged.size()); 645 646 // Add new entries 647 for (ResourceMethodDeclaration r : dedupeAndOrder(newlyCollected)) { 648 merged.put(generateKey(r), r); 649 } 650 651 // Optional prune by classfile existence (NOT IDE-safe by default) 652 if (pruneDeletedEnabled && classOutputRoot != null) { 653 merged.values().removeIf(r -> !classFileExistsInOutputRoot(classOutputRoot, r.className())); 654 debug("SokletProcessor: afterPruneDeleted=%d", merged.size()); 655 } 656 657 List<ResourceMethodDeclaration> toWrite = new ArrayList<>(merged.values()); 658 toWrite.sort(Comparator 659 .comparing((ResourceMethodDeclaration r) -> r.httpMethod().name()) 660 .thenComparing(ResourceMethodDeclaration::path) 661 .thenComparing(ResourceMethodDeclaration::className) 662 .thenComparing(ResourceMethodDeclaration::methodName)); 663 664 // Write CLASS_OUTPUT index (the real output) 665 writeRoutesIndexResource(toWrite, classOutputIndexPath, touchedTopLevelBinaries, newlyCollected); 666 667 // Write caches (best-effort) 668 if (sideCarIndexPath != null) writeIndexFileAtomically(sideCarIndexPath, toWrite); 669 if (persistentIndexPath != null) writeIndexFileAtomically(persistentIndexPath, toWrite); 670 671 debug("SokletProcessor: wroteIndexSize=%d", toWrite.size()); 672 } 673 674 private void removeTouchedEntries(Map<String, ResourceMethodDeclaration> merged, 675 Set<String> touchedTopLevelBinaries) { 676 if (touchedTopLevelBinaries == null || touchedTopLevelBinaries.isEmpty()) return; 677 678 merged.values().removeIf(r -> { 679 String ownerBin = r.className(); 680 for (String top : touchedTopLevelBinaries) { 681 if (ownerBin.equals(top) || ownerBin.startsWith(top + "$")) return true; 682 } 683 return false; 684 }); 685 } 686 687 private boolean readIndexFromLocation(StandardLocation location, Map<String, ResourceMethodDeclaration> out) { 688 try { 689 FileObject fo = filer.getResource(location, "", RESOURCE_METHOD_LOOKUP_TABLE_PATH); 690 try (BufferedReader reader = new BufferedReader(new InputStreamReader(fo.openInputStream(), StandardCharsets.UTF_8))) { 691 readIndexFromReader(reader, out); 692 } 693 return true; 694 } catch (IOException ignored) { 695 return false; 696 } 697 } 698 699 private boolean readIndexFromPath(Path path, Map<String, ResourceMethodDeclaration> out) { 700 if (path == null || !Files.isRegularFile(path)) return false; 701 try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { 702 readIndexFromReader(reader, out); 703 return true; 704 } catch (IOException ignored) { 705 return false; 706 } 707 } 708 709 private void readIndexFromReader(BufferedReader reader, Map<String, ResourceMethodDeclaration> out) throws IOException { 710 String line; 711 while ((line = reader.readLine()) != null) { 712 line = line.trim(); 713 if (line.isEmpty()) continue; 714 ResourceMethodDeclaration r = parseIndexLine(line); 715 if (r != null) out.put(generateKey(r), r); 716 } 717 } 718 719 private Path findClassOutputRoot() { 720 // Try to read an existing marker file 721 try { 722 FileObject fo = filer.getResource(StandardLocation.CLASS_OUTPUT, "", OUTPUT_ROOT_MARKER_PATH); 723 Path root = outputRootFromUri(fo.toUri(), OUTPUT_ROOT_MARKER_PATH); 724 if (root != null) return root; 725 } catch (IOException ignored) { 726 } 727 728 // Create marker to discover root 729 try { 730 FileObject fo = filer.createResource(StandardLocation.CLASS_OUTPUT, "", OUTPUT_ROOT_MARKER_PATH); 731 try (Writer w = fo.openWriter()) { 732 w.write(""); 733 } 734 return outputRootFromUri(fo.toUri(), OUTPUT_ROOT_MARKER_PATH); 735 } catch (IOException ignored) { 736 return null; 737 } 738 } 739 740 private Path sideCarIndexPath(Path classOutputRoot) { 741 if (classOutputRoot == null) return null; 742 Path parent = classOutputRoot.getParent(); 743 if (parent == null) return null; 744 String outputRootName = classOutputRoot.getFileName().toString(); 745 return parent.resolve(SIDE_CAR_DIR_NAME).resolve(outputRootName).resolve(SIDE_CAR_INDEX_FILENAME); 746 } 747 748 private Path persistentIndexPath(Path classOutputRoot) { 749 if (classOutputRoot == null) return null; 750 Path cacheRoot = persistentCacheRoot(); 751 if (cacheRoot == null) return null; 752 753 String key = hashPath(classOutputRoot.toAbsolutePath().normalize().toString()); 754 return cacheRoot.resolve(PERSISTENT_CACHE_INDEX_DIR).resolve(key).resolve(SIDE_CAR_INDEX_FILENAME); 755 } 756 757 /** 758 * Persistent caching is only enabled when soklet.cacheDir is explicitly set. 759 * This avoids writing project-root ".soklet" directories by default. 760 */ 761 private Path persistentCacheRoot() { 762 String override = processingEnv.getOptions().get(PROCESSOR_OPTION_CACHE_DIR); 763 if (override == null || override.isBlank()) return null; 764 try { 765 return Paths.get(override); 766 } catch (RuntimeException ignored) { 767 return null; 768 } 769 } 770 771 private boolean classFileExistsInOutputRoot(Path root, String binaryName) { 772 if (root == null) return true; 773 Path classFile = root.resolve(binaryName.replace('.', '/') + ".class"); 774 return Files.isRegularFile(classFile); 775 } 776 777 private Path outputRootFromUri(URI uri, String pathSuffix) { 778 if (uri == null || !"file".equalsIgnoreCase(uri.getScheme())) return null; 779 Path file = Paths.get(uri); 780 int segments = countPathSegments(pathSuffix); 781 Path root = file; 782 for (int i = 0; i < segments; i++) { 783 root = root.getParent(); 784 if (root == null) return null; 785 } 786 return root; 787 } 788 789 private ResourceMethodDeclaration parseIndexLine(String line) { 790 try { 791 String[] parts = line.split("\\|", -1); 792 if (parts.length < 6) return null; 793 794 HttpMethod httpMethod = HttpMethod.valueOf(parts[0]); 795 Base64.Decoder dec = Base64.getDecoder(); 796 797 String path = new String(dec.decode(parts[1]), StandardCharsets.UTF_8); 798 String className = new String(dec.decode(parts[2]), StandardCharsets.UTF_8); 799 String methodName = new String(dec.decode(parts[3]), StandardCharsets.UTF_8); 800 String paramsJoined = new String(dec.decode(parts[4]), StandardCharsets.UTF_8); 801 boolean sse = Boolean.parseBoolean(parts[5]); 802 803 String[] paramTypes; 804 if (paramsJoined.isEmpty()) { 805 paramTypes = new String[0]; 806 } else { 807 List<String> tmp = Arrays.stream(paramsJoined.split(";")) 808 .filter(s -> !s.isEmpty()) 809 .collect(Collectors.toList()); 810 paramTypes = tmp.toArray(String[]::new); 811 } 812 813 return new ResourceMethodDeclaration(httpMethod, path, className, methodName, paramTypes, sse); 814 } catch (Throwable t) { 815 return null; 816 } 817 } 818 819 /** 820 * Writes the merged index to CLASS_OUTPUT. 821 * Uses originating elements (best effort) so incremental build tools can track dependencies. 822 * 823 * <p>Fallback strategy if createResource fails: 824 * <ol> 825 * <li>Try opening a writer on filer.getResource(...)</li> 826 * <li>Try direct filesystem write if classOutputIndexPath is available</li> 827 * </ol> 828 */ 829 private void writeRoutesIndexResource(List<ResourceMethodDeclaration> routes, 830 Path classOutputIndexPath, 831 Set<String> touchedTopLevelBinaries, 832 List<ResourceMethodDeclaration> newlyCollected) { 833 Element[] origins = computeOriginatingElements(touchedTopLevelBinaries, newlyCollected); 834 835 try { 836 FileObject fo = filer.createResource(StandardLocation.CLASS_OUTPUT, "", RESOURCE_METHOD_LOOKUP_TABLE_PATH, origins); 837 try (Writer w = fo.openWriter()) { 838 writeIndexToWriter(w, routes); 839 } 840 return; 841 } catch (FilerException exists) { 842 // Try writing via getResource/openWriter 843 try { 844 FileObject fo = filer.getResource(StandardLocation.CLASS_OUTPUT, "", RESOURCE_METHOD_LOOKUP_TABLE_PATH); 845 try (Writer w = fo.openWriter()) { 846 writeIndexToWriter(w, routes); 847 } 848 return; 849 } catch (IOException ignored) { 850 // Fall through to direct path write if available 851 } 852 } catch (IOException e) { 853 // Fall through to direct path write if available 854 debug("SokletProcessor: filer.createResource/openWriter failed (%s); attempting direct write.", e); 855 } 856 857 // Direct path write (best effort) 858 if (classOutputIndexPath != null) { 859 try { 860 writeIndexFileAtomicallyOrThrow(classOutputIndexPath, routes); 861 return; 862 } catch (IOException e) { 863 throw new UncheckedIOException("Failed to write " + RESOURCE_METHOD_LOOKUP_TABLE_PATH, e); 864 } 865 } 866 867 throw new UncheckedIOException("Failed to write " + RESOURCE_METHOD_LOOKUP_TABLE_PATH, new IOException("No writable CLASS_OUTPUT path available")); 868 } 869 870 private Element[] computeOriginatingElements(Set<String> touchedTopLevelBinaries, 871 List<ResourceMethodDeclaration> newlyCollected) { 872 Set<Element> origins = new LinkedHashSet<>(); 873 874 // Always include touched top-level types (these are definitely in this compilation) 875 if (touchedTopLevelBinaries != null) { 876 for (String top : touchedTopLevelBinaries) { 877 TypeElement te = elements.getTypeElement(top); 878 if (te != null) origins.add(te); 879 } 880 } 881 882 // Also include owners of newly collected routes (top-level if possible) 883 if (newlyCollected != null) { 884 for (ResourceMethodDeclaration r : newlyCollected) { 885 String bin = r.className(); 886 int dollar = bin.indexOf('$'); 887 String top = (dollar >= 0) ? bin.substring(0, dollar) : bin; 888 889 TypeElement te = elements.getTypeElement(top); 890 if (te != null) origins.add(te); 891 } 892 } 893 894 return origins.toArray(new Element[0]); 895 } 896 897 private void writeIndexToWriter(Writer w, List<ResourceMethodDeclaration> routes) throws IOException { 898 Base64.Encoder b64 = Base64.getEncoder(); 899 for (ResourceMethodDeclaration r : routes) { 900 String params = String.join(";", r.parameterTypes()); 901 String line = String.join("|", 902 r.httpMethod().name(), 903 b64encode(b64, r.path()), 904 b64encode(b64, r.className()), 905 b64encode(b64, r.methodName()), 906 b64encode(b64, params), 907 Boolean.toString(r.serverSentEventSource()) 908 ); 909 w.write(line); 910 w.write('\n'); 911 } 912 } 913 914 /** 915 * Best-effort atomic write. Failures are logged (if debug enabled) and ignored. 916 */ 917 private void writeIndexFileAtomically(Path target, List<ResourceMethodDeclaration> routes) { 918 if (target == null) return; 919 try { 920 writeIndexFileAtomicallyOrThrow(target, routes); 921 } catch (IOException e) { 922 debug("SokletProcessor: failed to write cache index %s (%s)", target, e); 923 } 924 } 925 926 private void writeIndexFileAtomicallyOrThrow(Path target, List<ResourceMethodDeclaration> routes) throws IOException { 927 Path parent = target.getParent(); 928 if (parent != null) Files.createDirectories(parent); 929 930 // temp file in same dir so move is atomic on most filesystems 931 Path tmp = Files.createTempFile(parent, target.getFileName().toString(), ".tmp"); 932 try (Writer w = Files.newBufferedWriter(tmp, StandardCharsets.UTF_8)) { 933 writeIndexToWriter(w, routes); 934 } 935 936 try { 937 Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); 938 } catch (AtomicMoveNotSupportedException e) { 939 Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); 940 } 941 } 942 943 private static String b64encode(Base64.Encoder enc, String s) { 944 byte[] bytes = (s == null ? new byte[0] : s.getBytes(StandardCharsets.UTF_8)); 945 return enc.encodeToString(bytes); 946 } 947 948 // ---- Messaging ------------------------------------------------------------ 949 950 private void error(Element e, String fmt, Object... args) { 951 messager.printMessage(Diagnostic.Kind.ERROR, String.format(fmt, args), e); 952 } 953 954 private void debug(String fmt, Object... args) { 955 if (!debugEnabled) return; 956 messager.printMessage(Diagnostic.Kind.NOTE, String.format(fmt, args)); 957 } 958 959 // ---- Misc helpers --------------------------------------------------------- 960 961 private static CacheMode parseCacheMode(String option) { 962 if (option == null || option.isBlank()) return CacheMode.SIDECAR; 963 964 String normalized = option.trim().toLowerCase(Locale.ROOT); 965 switch (normalized) { 966 case "none": 967 case "off": 968 case "false": 969 return CacheMode.NONE; 970 case "sidecar": 971 return CacheMode.SIDECAR; 972 case "persistent": 973 case "persist": 974 return CacheMode.PERSISTENT; 975 default: 976 // Unknown -> default to sidecar for safety 977 return CacheMode.SIDECAR; 978 } 979 } 980 981 private static boolean parseBooleanishOption(String option) { 982 if (option == null) return false; 983 String normalized = option.trim(); 984 if (normalized.isEmpty()) return false; 985 return !"false".equalsIgnoreCase(normalized); 986 } 987 988 private static String hashPath(String input) { 989 try { 990 MessageDigest digest = MessageDigest.getInstance("SHA-1"); 991 byte[] bytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); 992 return toHex(bytes); 993 } catch (NoSuchAlgorithmException e) { 994 return Integer.toHexString(input.hashCode()); 995 } 996 } 997 998 private static String toHex(byte[] bytes) { 999 char[] out = new char[bytes.length * 2]; 1000 char[] digits = "0123456789abcdef".toCharArray(); 1001 for (int i = 0; i < bytes.length; i++) { 1002 int v = bytes[i] & 0xFF; 1003 out[i * 2] = digits[v >>> 4]; 1004 out[i * 2 + 1] = digits[v & 0x0F]; 1005 } 1006 return new String(out); 1007 } 1008 1009 private static int countPathSegments(String path) { 1010 int count = 1; 1011 for (int i = 0; i < path.length(); i++) { 1012 if (path.charAt(i) == '/') count++; 1013 } 1014 return count; 1015 } 1016 1017 private static String generateKey(ResourceMethodDeclaration r) { 1018 return r.httpMethod().name() + "|" + r.path() + "|" + r.className() + "|" + 1019 r.methodName() + "|" + String.join(";", r.parameterTypes()) + "|" + 1020 r.serverSentEventSource(); 1021 } 1022 1023 private static List<ResourceMethodDeclaration> dedupeAndOrder(List<ResourceMethodDeclaration> in) { 1024 Map<String, ResourceMethodDeclaration> byKey = new LinkedHashMap<>(); 1025 for (ResourceMethodDeclaration r : in) byKey.putIfAbsent(generateKey(r), r); 1026 1027 List<ResourceMethodDeclaration> out = new ArrayList<>(byKey.values()); 1028 out.sort(Comparator 1029 .comparing((ResourceMethodDeclaration r) -> r.httpMethod().name()) 1030 .thenComparing(ResourceMethodDeclaration::path) 1031 .thenComparing(ResourceMethodDeclaration::className) 1032 .thenComparing(ResourceMethodDeclaration::methodName)); 1033 return out; 1034 } 1035}