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.McpListResources; 023import com.soklet.annotation.McpPrompt; 024import com.soklet.annotation.McpResource; 025import com.soklet.annotation.McpServerEndpoint; 026import com.soklet.annotation.McpTool; 027import com.soklet.annotation.OPTIONS; 028import com.soklet.annotation.PATCH; 029import com.soklet.annotation.POST; 030import com.soklet.annotation.PUT; 031import com.soklet.annotation.SseEventSource; 032import org.jspecify.annotations.NonNull; 033 034import javax.annotation.concurrent.NotThreadSafe; 035import javax.annotation.processing.AbstractProcessor; 036import javax.annotation.processing.Filer; 037import javax.annotation.processing.FilerException; 038import javax.annotation.processing.Messager; 039import javax.annotation.processing.ProcessingEnvironment; 040import javax.annotation.processing.RoundEnvironment; 041import javax.lang.model.SourceVersion; 042import javax.lang.model.element.AnnotationMirror; 043import javax.lang.model.element.AnnotationValue; 044import javax.lang.model.element.Element; 045import javax.lang.model.element.ElementKind; 046import javax.lang.model.element.ExecutableElement; 047import javax.lang.model.element.Modifier; 048import javax.lang.model.element.TypeElement; 049import javax.lang.model.element.VariableElement; 050import javax.lang.model.type.TypeMirror; 051import javax.lang.model.util.Elements; 052import javax.lang.model.util.Types; 053import javax.tools.Diagnostic; 054import javax.tools.FileObject; 055import javax.tools.StandardLocation; 056import java.io.BufferedReader; 057import java.io.IOException; 058import java.io.InputStreamReader; 059import java.io.UncheckedIOException; 060import java.io.Writer; 061import java.lang.annotation.Annotation; 062import java.lang.annotation.Repeatable; 063import java.net.URI; 064import java.nio.charset.StandardCharsets; 065import java.nio.file.AtomicMoveNotSupportedException; 066import java.nio.file.Files; 067import java.nio.file.Path; 068import java.nio.file.Paths; 069import java.nio.file.StandardCopyOption; 070import java.security.MessageDigest; 071import java.security.NoSuchAlgorithmException; 072import java.util.ArrayList; 073import java.util.Arrays; 074import java.util.Base64; 075import java.util.Collections; 076import java.util.Comparator; 077import java.util.LinkedHashMap; 078import java.util.LinkedHashSet; 079import java.util.List; 080import java.util.Locale; 081import java.util.Map; 082import java.util.Set; 083import java.util.stream.Collectors; 084 085/** 086 * 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. 087 * <p> 088 * This Annotation Processor ensures <em>Resource Methods</em> annotated with {@link SseEventSource} are declared as returning an instance of {@link SseHandshakeResult}. 089 * <p> 090 * Your build system should ensure this Annotation Processor is available at compile time. Follow the instructions below to make your application conformant: 091 * <p> 092 * Using {@code javac} directly: 093 * <pre>javac -parameters -processor com.soklet.SokletProcessor ...[rest of javac command elided]</pre> 094 * Using <a href="https://maven.apache.org" target="_blank">Maven</a>: 095 * <pre>{@code <plugin> 096 * <groupId>org.apache.maven.plugins</groupId> 097 * <artifactId>maven-compiler-plugin</artifactId> 098 * <version>...</version> 099 * <configuration> 100 * <release>...</release> 101 * <compilerArgs> 102 * <!-- Rest of args elided --> 103 * <arg>-parameters</arg> 104 * <arg>-processor</arg> 105 * <arg>com.soklet.SokletProcessor</arg> 106 * </compilerArgs> 107 * </configuration> 108 * </plugin>}</pre> 109 * Using <a href="https://gradle.org" target="_blank">Gradle</a>: 110 * <pre>{@code def sokletVersion = "3.0.1" // (use your actual version) 111 * 112 * dependencies { 113 * // Soklet used by your code at compile/run time 114 * implementation "com.soklet:soklet:${sokletVersion}" 115 * 116 * // Same artifact also provides the annotation processor 117 * annotationProcessor "com.soklet:soklet:${sokletVersion}" 118 * 119 * // If tests also need processing (optional) 120 * testAnnotationProcessor "com.soklet:soklet:${sokletVersion}" 121 * }}</pre> 122 * 123 * <p><strong>Incremental/IDE ("IntelliJ-safe") behavior</strong> 124 * <ul> 125 * <li>Never rebuilds the global index from only the currently-compiled sources. It always merges with the prior index.</li> 126 * <li>Only removes stale entries for top-level types compiled in the current compiler invocation (touched types).</li> 127 * <li>Skips writing the index entirely if compilation errors are present, preventing clobbering a good index.</li> 128 * <li>Writes with originating elements (best-effort) so incremental build tools can track dependencies.</li> 129 * </ul> 130 * 131 * <p><strong>Processor options</strong> 132 * <ul> 133 * <li><code>-Asoklet.cacheMode=none|sidecar|persistent</code> (default: <code>sidecar</code>)</li> 134 * <li><code>-Asoklet.cacheDir=/path</code> (used only when cacheMode=persistent; required to enable persistent)</li> 135 * <li><code>-Asoklet.pruneDeleted=true|false</code> (default: false; generally not IDE-safe)</li> 136 * <li><code>-Asoklet.debug=true|false</code> (default: false)</li> 137 * </ul> 138 * 139 * <p><strong>Important</strong>: This processor will never create a project-root <code>.soklet</code> directory by default. 140 * Persistent caching is only enabled when <code>cacheMode=persistent</code> <em>and</em> <code>soklet.cacheDir</code> is set. 141 * 142 * @author <a href="https://www.revetkn.com">Mark Allen</a> 143 */ 144@NotThreadSafe 145public final class SokletProcessor extends AbstractProcessor { 146 // ---- Options ------------------------------------------------------------ 147 148 private static final String PROCESSOR_OPTION_CACHE_MODE = "soklet.cacheMode"; 149 private static final String PROCESSOR_OPTION_CACHE_DIR = "soklet.cacheDir"; 150 private static final String PROCESSOR_OPTION_PRUNE_DELETED = "soklet.pruneDeleted"; 151 private static final String PROCESSOR_OPTION_DEBUG = "soklet.debug"; 152 153 private static final String PERSISTENT_CACHE_INDEX_DIR = "resource-methods"; 154 private static final String MCP_PERSISTENT_CACHE_INDEX_DIR = "mcp-endpoints"; 155 156 // ---- Index paths --------------------------------------------------------- 157 158 static final String RESOURCE_METHOD_LOOKUP_TABLE_PATH = "META-INF/soklet/resource-method-lookup-table"; 159 static final String MCP_ENDPOINT_LOOKUP_TABLE_PATH = "META-INF/soklet/mcp-endpoint-lookup-table"; 160 private static final String OUTPUT_ROOT_MARKER_PATH = "META-INF/soklet/.soklet-output-root"; 161 162 private static final String SIDE_CAR_DIR_NAME = "soklet"; 163 private static final String SIDE_CAR_INDEX_FILENAME = "resource-method-lookup-table"; 164 private static final String MCP_SIDE_CAR_INDEX_FILENAME = "mcp-endpoint-lookup-table"; 165 166 // ---- JSR-269 services ---------------------------------------------------- 167 168 private Types types; 169 private Elements elements; 170 private Messager messager; 171 private Filer filer; 172 173 private boolean debugEnabled; 174 private boolean pruneDeletedEnabled; 175 private CacheMode cacheMode; 176 177 // Cached mirrors resolved in init() 178 private TypeMirror sseHandshakeResultType; // com.soklet.SseHandshakeResult 179 private TypeElement pathParameterElement; // com.soklet.annotation.PathParameter 180 private TypeElement mcpEndpointElement; // com.soklet.McpEndpoint 181 182 // Collected during this compilation invocation 183 private final List<ResourceMethodDeclaration> collected = new ArrayList<>(); 184 private final List<McpEndpointDeclaration> collectedMcpEndpoints = new ArrayList<>(); 185 private final Set<String> touchedTopLevelBinaries = new LinkedHashSet<>(); 186 private boolean resourceMethodAmbiguityDetected; 187 188 // ---- Supported annotations ---------------------------------------------- 189 190 private static final List<Class<? extends Annotation>> HTTP_AND_SSE_ANNOTATIONS = List.of( 191 GET.class, POST.class, PUT.class, PATCH.class, DELETE.class, HEAD.class, OPTIONS.class, 192 SseEventSource.class 193 ); 194 private static final List<Class<? extends Annotation>> MCP_ANNOTATIONS = List.of( 195 McpServerEndpoint.class, McpTool.class, McpPrompt.class, McpResource.class, McpListResources.class 196 ); 197 198 // ---- Cache modes --------------------------------------------------------- 199 200 private enum CacheMode { 201 NONE, // Only CLASS_OUTPUT index. No sidecar/persistent. Lowest clutter, lowest resiliency. 202 SIDECAR, // CLASS_OUTPUT + sidecar (under the class output parent directory). Default. 203 PERSISTENT // CLASS_OUTPUT + sidecar + persistent (under soklet.cacheDir). Requires soklet.cacheDir. 204 } 205 206 @Override 207 public synchronized void init(ProcessingEnvironment processingEnv) { 208 super.init(processingEnv); 209 this.types = processingEnv.getTypeUtils(); 210 this.elements = processingEnv.getElementUtils(); 211 this.messager = processingEnv.getMessager(); 212 this.filer = processingEnv.getFiler(); 213 214 this.debugEnabled = parseBooleanishOption(processingEnv.getOptions().get(PROCESSOR_OPTION_DEBUG)); 215 this.pruneDeletedEnabled = parseBooleanishOption(processingEnv.getOptions().get(PROCESSOR_OPTION_PRUNE_DELETED)); 216 this.cacheMode = parseCacheMode(processingEnv.getOptions().get(PROCESSOR_OPTION_CACHE_MODE)); 217 218 TypeElement hr = elements.getTypeElement("com.soklet.SseHandshakeResult"); 219 this.sseHandshakeResultType = (hr == null ? null : hr.asType()); 220 this.pathParameterElement = elements.getTypeElement("com.soklet.annotation.PathParameter"); 221 this.mcpEndpointElement = elements.getTypeElement("com.soklet.McpEndpoint"); 222 223 // If persistent mode was requested but cacheDir isn't configured, downgrade to SIDECAR. 224 if (this.cacheMode == CacheMode.PERSISTENT && persistentCacheRoot() == null) { 225 debug("SokletProcessor: cacheMode=persistent requested but %s not set/invalid; falling back to sidecar.", 226 PROCESSOR_OPTION_CACHE_DIR); 227 this.cacheMode = CacheMode.SIDECAR; 228 } 229 } 230 231 @Override 232 public Set<String> getSupportedAnnotationTypes() { 233 Set<String> out = new LinkedHashSet<>(); 234 for (Class<? extends Annotation> c : HTTP_AND_SSE_ANNOTATIONS) { 235 out.add(c.getCanonicalName()); 236 Class<? extends Annotation> container = findRepeatableContainer(c); 237 if (container != null) out.add(container.getCanonicalName()); 238 } 239 for (Class<? extends Annotation> c : MCP_ANNOTATIONS) 240 out.add(c.getCanonicalName()); 241 return out; 242 } 243 244 @Override 245 public SourceVersion getSupportedSourceVersion() { 246 return SourceVersion.latestSupported(); 247 } 248 249 @Override 250 public Set<String> getSupportedOptions() { 251 return new LinkedHashSet<>(List.of( 252 PROCESSOR_OPTION_CACHE_MODE, 253 PROCESSOR_OPTION_CACHE_DIR, 254 PROCESSOR_OPTION_PRUNE_DELETED, 255 PROCESSOR_OPTION_DEBUG 256 )); 257 } 258 259 @Override 260 public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { 261 // Track top-level types being compiled in this invocation. 262 for (Element root : roundEnv.getRootElements()) { 263 if (root instanceof TypeElement te) { 264 String bin = elements.getBinaryName(te).toString(); 265 touchedTopLevelBinaries.add(bin); 266 } 267 } 268 269 // SSE-specific return type check 270 enforceSseReturnTypes(roundEnv); 271 272 // Collect + validate 273 collect(roundEnv, HttpMethod.GET, GET.class, false); 274 collect(roundEnv, HttpMethod.POST, POST.class, false); 275 collect(roundEnv, HttpMethod.PUT, PUT.class, false); 276 collect(roundEnv, HttpMethod.PATCH, PATCH.class, false); 277 collect(roundEnv, HttpMethod.DELETE, DELETE.class, false); 278 collect(roundEnv, HttpMethod.HEAD, HEAD.class, false); 279 collect(roundEnv, HttpMethod.OPTIONS, OPTIONS.class, false); 280 collect(roundEnv, HttpMethod.GET, SseEventSource.class, true); // SSE as GET + flag 281 collectMcpEndpoints(roundEnv); 282 283 if (roundEnv.processingOver()) { 284 // Critical: don't overwrite a good index with a partial/failed compile. 285 if (roundEnv.errorRaised() || resourceMethodAmbiguityDetected) { 286 debug("SokletProcessor: compilation has errors; skipping index write to avoid clobbering."); 287 return false; 288 } 289 mergeAndWriteIndex(collected, touchedTopLevelBinaries); 290 mergeAndWriteMcpIndex(collectedMcpEndpoints, touchedTopLevelBinaries); 291 } 292 293 return false; 294 } 295 296 /** 297 * Collects and validates each annotated method occurrence (repeatable-aware, without reflection). 298 */ 299 private void collect(RoundEnvironment roundEnv, 300 HttpMethod httpMethod, 301 Class<? extends Annotation> baseAnnotation, 302 boolean sseEventSource) { 303 304 TypeElement base = elements.getTypeElement(baseAnnotation.getCanonicalName()); 305 Class<? extends Annotation> containerClass = findRepeatableContainer(baseAnnotation); 306 TypeElement container = containerClass == null ? null : elements.getTypeElement(containerClass.getCanonicalName()); 307 308 Set<Element> candidates = new LinkedHashSet<>(); 309 if (base != null) candidates.addAll(roundEnv.getElementsAnnotatedWith(base)); 310 if (container != null) candidates.addAll(roundEnv.getElementsAnnotatedWith(container)); 311 312 for (Element e : candidates) { 313 if (e.getKind() != ElementKind.METHOD) { 314 error(e, "Soklet: @%s can only be applied to methods.", baseAnnotation.getSimpleName()); 315 continue; 316 } 317 318 ExecutableElement method = (ExecutableElement) e; 319 TypeElement owner = (TypeElement) method.getEnclosingElement(); 320 321 boolean isPublic = method.getModifiers().contains(Modifier.PUBLIC); 322 boolean isStatic = method.getModifiers().contains(Modifier.STATIC); 323 324 if (isStatic) error(method, "Soklet: Resource Method must not be static"); 325 if (!isPublic) error(method, "Soklet: Resource Method must be public"); 326 327 // Extract each occurrence as an AnnotationMirror (handles repeatable containers) 328 List<AnnotationMirror> occurrences = extractOccurrences(method, base, container); 329 330 for (AnnotationMirror annMirror : occurrences) { 331 String rawPath = readAnnotationStringMember(annMirror, "value"); 332 if (rawPath == null || rawPath.isBlank()) { 333 error(method, "Soklet: @%s must have a non-empty path value", baseAnnotation.getSimpleName()); 334 continue; 335 } 336 337 String path = normalizePath(rawPath); 338 339 ValidationResult vr = validatePathTemplate(method, path); 340 if (!vr.ok) continue; 341 342 ParamBindings pb = readPathParameterBindings(method); 343 344 // a) placeholders must be bound 345 for (String placeholder : vr.placeholders) { 346 if (!pb.paramNames.contains(placeholder)) { 347 String shown = vr.original.getOrDefault(placeholder, placeholder); 348 error(method, "Resource Method path parameter {" + shown + "} not bound to a @PathParameter argument"); 349 } 350 } 351 352 // b) annotated params must exist in template 353 for (String annotated : pb.paramNames) { 354 if (!vr.placeholders.contains(annotated)) { 355 error(method, "No placeholder {" + annotated + "} present in resource path declaration"); 356 } 357 } 358 359 // Only collect if this method is otherwise valid 360 if (!pb.hadError && vr.ok && isPublic && !isStatic) { 361 String className = elements.getBinaryName(owner).toString(); 362 String methodName = method.getSimpleName().toString(); 363 String[] paramTypes = method.getParameters().stream() 364 .map(p -> jvmTypeName(p.asType())) 365 .toArray(String[]::new); 366 367 ResourceMethodDeclaration declaration = new ResourceMethodDeclaration( 368 httpMethod, path, className, methodName, paramTypes, sseEventSource 369 ); 370 detectResourceMethodAmbiguity(method, declaration); 371 collected.add(declaration); 372 } 373 } 374 } 375 } 376 377 private List<AnnotationMirror> extractOccurrences(ExecutableElement method, TypeElement base, TypeElement container) { 378 List<AnnotationMirror> out = new ArrayList<>(); 379 380 for (AnnotationMirror am : method.getAnnotationMirrors()) { 381 if (base != null && isAnnotationType(am, base)) { 382 out.add(am); 383 } else if (container != null && isAnnotationType(am, container)) { 384 Object v = readAnnotationMemberValue(am, "value"); 385 if (v instanceof List<?> list) { 386 for (Object o : list) { 387 if (o instanceof AnnotationValue av) { 388 Object inner = av.getValue(); 389 if (inner instanceof AnnotationMirror innerAm) { 390 out.add(innerAm); 391 } 392 } 393 } 394 } 395 } 396 } 397 398 return out; 399 } 400 401 // --- Helpers for parameter annotations ------------------------------------ 402 403 private static final class ParamBindings { 404 final Set<String> paramNames; 405 final boolean hadError; 406 407 ParamBindings(Set<String> names, boolean hadError) { 408 this.paramNames = names; 409 this.hadError = hadError; 410 } 411 } 412 413 private ParamBindings readPathParameterBindings(ExecutableElement method) { 414 boolean hadError = false; 415 Set<String> names = new LinkedHashSet<>(); 416 if (pathParameterElement == null) return new ParamBindings(names, false); 417 418 for (VariableElement p : method.getParameters()) { 419 for (AnnotationMirror am : p.getAnnotationMirrors()) { 420 if (isAnnotationType(am, pathParameterElement)) { 421 // 1) try explicit annotation member 422 String name = readAnnotationStringMember(am, "name"); 423 // 2) default to the parameter's source name if missing/blank 424 if (name == null || name.isBlank()) { 425 name = p.getSimpleName().toString(); 426 } 427 if (name != null && !name.isBlank()) { 428 names.add(name); 429 } 430 } 431 } 432 } 433 434 return new ParamBindings(names, hadError); 435 } 436 437 private static boolean isAnnotationType(AnnotationMirror am, TypeElement type) { 438 return am.getAnnotationType().asElement().equals(type); 439 } 440 441 private static Object readAnnotationMemberValue(AnnotationMirror am, String member) { 442 for (Map.Entry<? extends ExecutableElement, ? extends AnnotationValue> e : am.getElementValues().entrySet()) { 443 if (e.getKey().getSimpleName().contentEquals(member)) { 444 return e.getValue().getValue(); 445 } 446 } 447 return null; 448 } 449 450 private static String readAnnotationStringMember(AnnotationMirror am, String member) { 451 Object v = readAnnotationMemberValue(am, member); 452 return (v == null) ? null : v.toString(); 453 } 454 455 // --- Path parsing/validation ---------------------------------------------- 456 457 private static final class ValidationResult { 458 final boolean ok; 459 final Set<String> placeholders; // normalized names (no trailing '*') 460 final Map<String, String> original; // normalized -> original token 461 462 ValidationResult(boolean ok, Set<String> placeholders, Map<String, String> original) { 463 this.ok = ok; 464 this.placeholders = placeholders; 465 this.original = original; 466 } 467 } 468 469 /** 470 * Validates braces and duplicate placeholders (treating {name*} as a greedy/varargs placeholder whose 471 * logical name is "name"). Duplicate detection is done on the normalized name (without trailing '*'). 472 */ 473 private ValidationResult validatePathTemplate(Element reportOn, String path) { 474 if (path == null || path.isEmpty()) { 475 return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap()); 476 } 477 478 Set<String> names = new LinkedHashSet<>(); 479 Map<String, String> originalTokens = new LinkedHashMap<>(); 480 481 int i = 0; 482 while (i < path.length()) { 483 char c = path.charAt(i); 484 if (c == '{') { 485 int close = path.indexOf('}', i + 1); 486 if (close < 0) { 487 error(reportOn, "Soklet: Malformed resource path declaration (unbalanced braces)"); 488 return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap()); 489 } 490 491 String token = path.substring(i + 1, close); // e.g., "id", "cssPath*" 492 if (token.isEmpty()) { 493 error(reportOn, "Soklet: Malformed resource path declaration (unbalanced braces)"); 494 return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap()); 495 } 496 497 String normalized = normalizePlaceholder(token); 498 if (normalized.isEmpty()) { 499 error(reportOn, "Soklet: Malformed resource path declaration (unbalanced braces)"); 500 return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap()); 501 } 502 503 if (!names.add(normalized)) { 504 error(reportOn, "Soklet: Duplicate @PathParameter name: " + normalized); 505 } 506 originalTokens.putIfAbsent(normalized, token); 507 508 i = close + 1; 509 } else if (c == '}') { 510 error(reportOn, "Soklet: Malformed resource path declaration (unbalanced braces)"); 511 return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap()); 512 } else { 513 i++; 514 } 515 } 516 517 return new ValidationResult(true, names, originalTokens); 518 } 519 520 private static String normalizePlaceholder(String token) { 521 if (token.endsWith("*")) return token.substring(0, token.length() - 1); 522 return token; 523 } 524 525 // --- MCP collection/validation -------------------------------------------- 526 527 private void collectMcpEndpoints(RoundEnvironment roundEnv) { 528 TypeElement mcpServerEndpointType = elements.getTypeElement(McpServerEndpoint.class.getCanonicalName()); 529 530 if (mcpServerEndpointType != null) { 531 for (Element element : roundEnv.getElementsAnnotatedWith(mcpServerEndpointType)) { 532 if (element.getKind() != ElementKind.CLASS) { 533 error(element, "Soklet: @%s can only be applied to classes.", McpServerEndpoint.class.getSimpleName()); 534 continue; 535 } 536 537 validateAndCollectMcpEndpoint((TypeElement) element); 538 } 539 } 540 541 validateMcpAnnotatedMethodsBelongToEndpoint(roundEnv, McpTool.class); 542 validateMcpAnnotatedMethodsBelongToEndpoint(roundEnv, McpPrompt.class); 543 validateMcpAnnotatedMethodsBelongToEndpoint(roundEnv, McpResource.class); 544 validateMcpAnnotatedMethodsBelongToEndpoint(roundEnv, McpListResources.class); 545 } 546 547 private void validateAndCollectMcpEndpoint(TypeElement endpointType) { 548 boolean valid = true; 549 550 if (mcpEndpointElement == null || !types.isAssignable(endpointType.asType(), mcpEndpointElement.asType())) { 551 error(endpointType, "Soklet: Classes annotated with @%s must implement %s.", 552 McpServerEndpoint.class.getSimpleName(), McpEndpoint.class.getSimpleName()); 553 valid = false; 554 } 555 556 McpServerEndpoint endpoint = endpointType.getAnnotation(McpServerEndpoint.class); 557 558 if (endpoint == null) 559 return; 560 561 if (endpoint.path().isBlank()) { 562 error(endpointType, "Soklet: @%s path must be non-empty", McpServerEndpoint.class.getSimpleName()); 563 valid = false; 564 } else { 565 ValidationResult validationResult = validatePathTemplate(endpointType, normalizePath(endpoint.path())); 566 valid = valid && validationResult.ok; 567 } 568 569 if (endpoint.name().isBlank()) { 570 error(endpointType, "Soklet: @%s name must be non-empty", McpServerEndpoint.class.getSimpleName()); 571 valid = false; 572 } 573 574 if (endpoint.version().isBlank()) { 575 error(endpointType, "Soklet: @%s version must be non-empty", McpServerEndpoint.class.getSimpleName()); 576 valid = false; 577 } 578 579 String websiteUrl = endpoint.websiteUrl(); 580 if (websiteUrl != null && !websiteUrl.isBlank() 581 && !(websiteUrl.startsWith("https://") || websiteUrl.startsWith("http://"))) { 582 error(endpointType, "Soklet: @%s websiteUrl must start with http:// or https://", McpServerEndpoint.class.getSimpleName()); 583 valid = false; 584 } 585 586 Set<String> toolNames = new LinkedHashSet<>(); 587 Set<String> promptNames = new LinkedHashSet<>(); 588 Set<String> resourceUris = new LinkedHashSet<>(); 589 Set<String> resourceNames = new LinkedHashSet<>(); 590 int resourceListMethodCount = 0; 591 592 for (Element enclosedElement : endpointType.getEnclosedElements()) { 593 if (enclosedElement.getKind() != ElementKind.METHOD) 594 continue; 595 596 ExecutableElement method = (ExecutableElement) enclosedElement; 597 598 if (method.getAnnotation(McpTool.class) != null) { 599 if (!validateMcpAnnotatedMethod(method, McpTool.class.getSimpleName())) 600 valid = false; 601 602 McpTool tool = method.getAnnotation(McpTool.class); 603 604 if (tool.name().isBlank()) { 605 error(method, "Soklet: @%s name must be non-empty", McpTool.class.getSimpleName()); 606 valid = false; 607 } else if (!toolNames.add(tool.name())) { 608 error(method, "Soklet: Duplicate MCP tool name '%s'", tool.name()); 609 valid = false; 610 } 611 612 if (tool.description().isBlank()) { 613 error(method, "Soklet: @%s description must be non-empty", McpTool.class.getSimpleName()); 614 valid = false; 615 } 616 } 617 618 if (method.getAnnotation(McpPrompt.class) != null) { 619 if (!validateMcpAnnotatedMethod(method, McpPrompt.class.getSimpleName())) 620 valid = false; 621 622 McpPrompt prompt = method.getAnnotation(McpPrompt.class); 623 624 if (prompt.name().isBlank()) { 625 error(method, "Soklet: @%s name must be non-empty", McpPrompt.class.getSimpleName()); 626 valid = false; 627 } else if (!promptNames.add(prompt.name())) { 628 error(method, "Soklet: Duplicate MCP prompt name '%s'", prompt.name()); 629 valid = false; 630 } 631 632 if (prompt.description().isBlank()) { 633 error(method, "Soklet: @%s description must be non-empty", McpPrompt.class.getSimpleName()); 634 valid = false; 635 } 636 } 637 638 if (method.getAnnotation(McpResource.class) != null) { 639 if (!validateMcpAnnotatedMethod(method, McpResource.class.getSimpleName())) 640 valid = false; 641 642 McpResource resource = method.getAnnotation(McpResource.class); 643 644 if (resource.uri().isBlank()) { 645 error(method, "Soklet: @%s uri must be non-empty", McpResource.class.getSimpleName()); 646 valid = false; 647 } else { 648 ValidationResult validationResult = validatePathTemplate(method, resource.uri()); 649 valid = valid && validationResult.ok; 650 651 if (!resourceUris.add(resource.uri())) { 652 error(method, "Soklet: Duplicate MCP resource URI '%s'", resource.uri()); 653 valid = false; 654 } 655 } 656 657 if (resource.name().isBlank()) { 658 error(method, "Soklet: @%s name must be non-empty", McpResource.class.getSimpleName()); 659 valid = false; 660 } else if (!resourceNames.add(resource.name())) { 661 error(method, "Soklet: Duplicate MCP resource name '%s'", resource.name()); 662 valid = false; 663 } 664 665 if (resource.mimeType().isBlank()) { 666 error(method, "Soklet: @%s mimeType must be non-empty", McpResource.class.getSimpleName()); 667 valid = false; 668 } 669 } 670 671 if (method.getAnnotation(McpListResources.class) != null) { 672 if (!validateMcpAnnotatedMethod(method, McpListResources.class.getSimpleName())) 673 valid = false; 674 675 resourceListMethodCount++; 676 } 677 } 678 679 if (resourceListMethodCount > 1) { 680 error(endpointType, "Soklet: At most one @%s method may be declared on an MCP endpoint class.", 681 McpListResources.class.getSimpleName()); 682 valid = false; 683 } 684 685 if (valid) 686 collectedMcpEndpoints.add(new McpEndpointDeclaration(elements.getBinaryName(endpointType).toString())); 687 } 688 689 private void validateMcpAnnotatedMethodsBelongToEndpoint(RoundEnvironment roundEnv, 690 Class<? extends Annotation> annotationType) { 691 TypeElement annotationElement = elements.getTypeElement(annotationType.getCanonicalName()); 692 693 if (annotationElement == null) 694 return; 695 696 for (Element element : roundEnv.getElementsAnnotatedWith(annotationElement)) { 697 if (element.getKind() != ElementKind.METHOD) { 698 error(element, "Soklet: @%s can only be applied to methods.", annotationType.getSimpleName()); 699 continue; 700 } 701 702 Element enclosingElement = element.getEnclosingElement(); 703 if (!(enclosingElement instanceof TypeElement enclosingType) 704 || enclosingType.getAnnotation(McpServerEndpoint.class) == null) { 705 error(element, "Soklet: Methods annotated with @%s must be declared on a class annotated with @%s.", 706 annotationType.getSimpleName(), McpServerEndpoint.class.getSimpleName()); 707 } 708 } 709 } 710 711 private boolean validateMcpAnnotatedMethod(ExecutableElement method, String annotationSimpleName) { 712 boolean valid = true; 713 714 if (!method.getModifiers().contains(Modifier.PUBLIC)) { 715 error(method, "Soklet: Methods annotated with @%s must be public.", annotationSimpleName); 716 valid = false; 717 } 718 719 if (method.getModifiers().contains(Modifier.STATIC)) { 720 error(method, "Soklet: Methods annotated with @%s must not be static.", annotationSimpleName); 721 valid = false; 722 } 723 724 return valid; 725 } 726 727 // --- Existing utilities ---------------------------------------------------- 728 729 private static String normalizePath(String p) { 730 if (p == null || p.isEmpty()) return "/"; 731 if (p.charAt(0) != '/') return "/" + p; 732 return p; 733 } 734 735 private static Class<? extends Annotation> findRepeatableContainer(Class<? extends Annotation> base) { 736 Repeatable repeatable = base.getAnnotation(Repeatable.class); 737 return (repeatable == null) ? null : repeatable.value(); 738 } 739 740 private String jvmTypeName(TypeMirror t) { 741 switch (t.getKind()) { 742 case BOOLEAN: 743 return "boolean"; 744 case BYTE: 745 return "byte"; 746 case SHORT: 747 return "short"; 748 case CHAR: 749 return "char"; 750 case INT: 751 return "int"; 752 case LONG: 753 return "long"; 754 case FLOAT: 755 return "float"; 756 case DOUBLE: 757 return "double"; 758 case VOID: 759 return "void"; 760 case ARRAY: 761 return "[" + jvmTypeDescriptor(((javax.lang.model.type.ArrayType) t).getComponentType()); 762 case DECLARED: 763 default: 764 TypeMirror erasure = processingEnv.getTypeUtils().erasure(t); 765 Element el = processingEnv.getTypeUtils().asElement(erasure); 766 if (el instanceof TypeElement te) { 767 return processingEnv.getElementUtils().getBinaryName(te).toString(); 768 } 769 return erasure.toString(); 770 } 771 } 772 773 private String jvmTypeDescriptor(TypeMirror t) { 774 switch (t.getKind()) { 775 case BOOLEAN: 776 return "Z"; 777 case BYTE: 778 return "B"; 779 case SHORT: 780 return "S"; 781 case CHAR: 782 return "C"; 783 case INT: 784 return "I"; 785 case LONG: 786 return "J"; 787 case FLOAT: 788 return "F"; 789 case DOUBLE: 790 return "D"; 791 case ARRAY: 792 return "[" + jvmTypeDescriptor(((javax.lang.model.type.ArrayType) t).getComponentType()); 793 case DECLARED: 794 default: 795 TypeMirror erasure = processingEnv.getTypeUtils().erasure(t); 796 Element el = processingEnv.getTypeUtils().asElement(erasure); 797 if (el instanceof TypeElement te) { 798 String bin = processingEnv.getElementUtils().getBinaryName(te).toString(); 799 return "L" + bin + ";"; 800 } 801 return "Ljava/lang/Object;"; 802 } 803 } 804 805 // ---- SSE return-type validation ------------------------------------------ 806 807 private void enforceSseReturnTypes(RoundEnvironment roundEnv) { 808 if (sseHandshakeResultType == null) return; 809 810 TypeElement sseAnn = elements.getTypeElement(SseEventSource.class.getCanonicalName()); 811 if (sseAnn == null) return; 812 813 for (Element e : roundEnv.getElementsAnnotatedWith(sseAnn)) { 814 if (e.getKind() != ElementKind.METHOD) { 815 error(e, "@%s can only be applied to methods.", SseEventSource.class.getSimpleName()); 816 continue; 817 } 818 ExecutableElement method = (ExecutableElement) e; 819 TypeMirror returnType = method.getReturnType(); 820 boolean assignable = types.isAssignable(returnType, sseHandshakeResultType); 821 if (!assignable) { 822 error(e, 823 "Soklet: Resource Methods annotated with @%s must specify a return type of %s (found: %s).", 824 SseEventSource.class.getSimpleName(), "SseHandshakeResult", prettyType(returnType)); 825 } 826 } 827 } 828 829 private static String prettyType(TypeMirror t) { 830 return (t == null ? "null" : t.toString()); 831 } 832 833 // ---- Index read/merge/write ---------------------------------------------- 834 835 private void detectResourceMethodAmbiguity(@NonNull Element element, 836 @NonNull ResourceMethodDeclaration declaration) { 837 for (ResourceMethodDeclaration existing : dedupeAndOrder(collected)) { 838 if (resourceMethodDeclarationsAmbiguous(existing, declaration)) { 839 resourceMethodAmbiguityDetected = true; 840 error(element, "Soklet: Ambiguous resource method declarations detected. %s overlaps %s", 841 describeResourceMethodDeclaration(declaration), describeResourceMethodDeclaration(existing)); 842 } 843 } 844 } 845 846 private static boolean resourceMethodDeclarationsAmbiguous(@NonNull ResourceMethodDeclaration first, 847 @NonNull ResourceMethodDeclaration second) { 848 if (generateKey(first).equals(generateKey(second))) 849 return false; 850 851 ResourcePathDeclaration firstPath = ResourcePathDeclaration.fromPath(first.path()); 852 ResourcePathDeclaration secondPath = ResourcePathDeclaration.fromPath(second.path()); 853 ResourceMethodSpecificityKey firstKey = specificityKey(first, firstPath); 854 ResourceMethodSpecificityKey secondKey = specificityKey(second, secondPath); 855 856 return firstKey.equals(secondKey) && resourcePathDeclarationsOverlap(firstPath, secondPath); 857 } 858 859 @NonNull 860 private static ResourceMethodSpecificityKey specificityKey(@NonNull ResourceMethodDeclaration declaration, 861 @NonNull ResourcePathDeclaration resourcePathDeclaration) { 862 return new ResourceMethodSpecificityKey( 863 declaration.httpMethod(), 864 declaration.sseEventSource(), 865 resourcePathDeclaration.getVarargsComponent().isPresent(), 866 placeholderCount(resourcePathDeclaration), 867 literalCount(resourcePathDeclaration)); 868 } 869 870 @NonNull 871 private static String describeResourceMethodDeclaration(@NonNull ResourceMethodDeclaration declaration) { 872 return String.format("%s %s %s -> %s#%s(%s)", 873 declaration.sseEventSource() ? "SSE" : "HTTP", 874 declaration.httpMethod().name(), 875 declaration.path(), 876 declaration.className(), 877 declaration.methodName(), 878 String.join(", ", declaration.parameterTypes())); 879 } 880 881 private static long placeholderCount(@NonNull ResourcePathDeclaration declaration) { 882 return declaration.getComponents().stream() 883 .filter(component -> component.getType() == ResourcePathDeclaration.ComponentType.PLACEHOLDER) 884 .count(); 885 } 886 887 private static long literalCount(@NonNull ResourcePathDeclaration declaration) { 888 return declaration.getComponents().stream() 889 .filter(component -> component.getType() == ResourcePathDeclaration.ComponentType.LITERAL) 890 .count(); 891 } 892 893 private static boolean resourcePathDeclarationsOverlap(@NonNull ResourcePathDeclaration first, 894 @NonNull ResourcePathDeclaration second) { 895 List<ResourcePathDeclaration.Component> firstComponents = first.getComponents(); 896 List<ResourcePathDeclaration.Component> secondComponents = second.getComponents(); 897 898 boolean firstHasVarargs = first.getVarargsComponent().isPresent(); 899 boolean secondHasVarargs = second.getVarargsComponent().isPresent(); 900 901 int firstPrefixLength = firstComponents.size() - (firstHasVarargs ? 1 : 0); 902 int secondPrefixLength = secondComponents.size() - (secondHasVarargs ? 1 : 0); 903 904 if (!firstHasVarargs && !secondHasVarargs) { 905 if (firstComponents.size() != secondComponents.size()) 906 return false; 907 908 for (int i = 0; i < firstComponents.size(); i++) 909 if (!componentsCompatible(firstComponents.get(i), secondComponents.get(i))) 910 return false; 911 912 return true; 913 } 914 915 if (firstHasVarargs && !secondHasVarargs) { 916 if (secondComponents.size() < firstPrefixLength) 917 return false; 918 919 for (int i = 0; i < firstPrefixLength; i++) 920 if (!componentsCompatible(firstComponents.get(i), secondComponents.get(i))) 921 return false; 922 923 return true; 924 } 925 926 if (!firstHasVarargs) { 927 if (firstComponents.size() < secondPrefixLength) 928 return false; 929 930 for (int i = 0; i < secondPrefixLength; i++) 931 if (!componentsCompatible(firstComponents.get(i), secondComponents.get(i))) 932 return false; 933 934 return true; 935 } 936 937 int minPrefixLength = Math.min(firstPrefixLength, secondPrefixLength); 938 939 for (int i = 0; i < minPrefixLength; i++) 940 if (!componentsCompatible(firstComponents.get(i), secondComponents.get(i))) 941 return false; 942 943 return true; 944 } 945 946 private static boolean componentsCompatible(ResourcePathDeclaration.@NonNull Component first, 947 ResourcePathDeclaration.@NonNull Component second) { 948 if (first.getType() == ResourcePathDeclaration.ComponentType.LITERAL 949 && second.getType() == ResourcePathDeclaration.ComponentType.LITERAL) 950 return first.getValue().equals(second.getValue()); 951 952 return true; 953 } 954 955 private void mergeAndWriteIndex(List<ResourceMethodDeclaration> newlyCollected, 956 Set<String> touchedTopLevelBinaries) { 957 958 Path classOutputRoot = findClassOutputRoot(); 959 Path classOutputIndexPath = (classOutputRoot == null ? null : classOutputRoot.resolve(RESOURCE_METHOD_LOOKUP_TABLE_PATH)); 960 961 Path sideCarIndexPath = (cacheMode == CacheMode.NONE ? null : sideCarIndexPath(classOutputRoot)); 962 Path persistentIndexPath = (cacheMode == CacheMode.PERSISTENT ? persistentIndexPath(classOutputRoot) : null); 963 964 debug("SokletProcessor: cacheMode=%s", cacheMode); 965 debug("SokletProcessor: classOutputRoot=%s", classOutputRoot); 966 debug("SokletProcessor: classOutputIndexPath=%s", classOutputIndexPath); 967 debug("SokletProcessor: sidecarIndexPath=%s", sideCarIndexPath); 968 debug("SokletProcessor: persistentIndexPath=%s", persistentIndexPath); 969 debug("SokletProcessor: touchedTopLevels=%s", touchedTopLevelBinaries); 970 971 // Always merge from ALL enabled sources. Never "fallback only if empty". 972 Map<String, ResourceMethodDeclaration> merged = new LinkedHashMap<>(); 973 974 // Oldest/most durable first 975 if (persistentIndexPath != null) readIndexFromPath(persistentIndexPath, merged); 976 if (sideCarIndexPath != null) readIndexFromPath(sideCarIndexPath, merged); 977 978 // Then current output dir (direct file access, if possible) 979 if (classOutputIndexPath != null) readIndexFromPath(classOutputIndexPath, merged); 980 981 // Then via filer (often works even if direct file paths don't) 982 readIndexFromLocation(StandardLocation.CLASS_OUTPUT, merged); 983 984 debug("SokletProcessor: mergedExistingIndexSize=%d", merged.size()); 985 986 // Remove stale entries for classes being recompiled now (top-level + nested) 987 removeTouchedEntries(merged, touchedTopLevelBinaries); 988 debug("SokletProcessor: afterRemovingTouched=%d", merged.size()); 989 990 // Add new entries 991 for (ResourceMethodDeclaration r : dedupeAndOrder(newlyCollected)) { 992 merged.put(generateKey(r), r); 993 } 994 995 // Optional prune by classfile existence (NOT IDE-safe by default) 996 if (pruneDeletedEnabled && classOutputRoot != null) { 997 merged.values().removeIf(r -> !classFileExistsInOutputRoot(classOutputRoot, r.className())); 998 debug("SokletProcessor: afterPruneDeleted=%d", merged.size()); 999 } 1000 1001 List<ResourceMethodDeclaration> toWrite = new ArrayList<>(merged.values()); 1002 toWrite.sort(Comparator 1003 .comparing((ResourceMethodDeclaration r) -> r.httpMethod().name()) 1004 .thenComparing(ResourceMethodDeclaration::path) 1005 .thenComparing(ResourceMethodDeclaration::className) 1006 .thenComparing(ResourceMethodDeclaration::methodName)); 1007 1008 // Write CLASS_OUTPUT index (the real output) 1009 writeRoutesIndexResource(toWrite, classOutputIndexPath, touchedTopLevelBinaries, newlyCollected); 1010 1011 // Write caches (best-effort) 1012 if (sideCarIndexPath != null) writeIndexFileAtomically(sideCarIndexPath, toWrite); 1013 if (persistentIndexPath != null) writeIndexFileAtomically(persistentIndexPath, toWrite); 1014 1015 debug("SokletProcessor: wroteIndexSize=%d", toWrite.size()); 1016 } 1017 1018 private void mergeAndWriteMcpIndex(List<McpEndpointDeclaration> newlyCollected, 1019 Set<String> touchedTopLevelBinaries) { 1020 Path classOutputRoot = findClassOutputRoot(); 1021 Path classOutputIndexPath = (classOutputRoot == null ? null : classOutputRoot.resolve(MCP_ENDPOINT_LOOKUP_TABLE_PATH)); 1022 Path sideCarIndexPath = (cacheMode == CacheMode.NONE ? null : sideCarMcpIndexPath(classOutputRoot)); 1023 Path persistentIndexPath = (cacheMode == CacheMode.PERSISTENT ? persistentMcpIndexPath(classOutputRoot) : null); 1024 1025 Map<String, McpEndpointDeclaration> merged = new LinkedHashMap<>(); 1026 1027 if (persistentIndexPath != null) readMcpIndexFromPath(persistentIndexPath, merged); 1028 if (sideCarIndexPath != null) readMcpIndexFromPath(sideCarIndexPath, merged); 1029 if (classOutputIndexPath != null) readMcpIndexFromPath(classOutputIndexPath, merged); 1030 readMcpIndexFromLocation(StandardLocation.CLASS_OUTPUT, merged); 1031 1032 removeTouchedMcpEntries(merged, touchedTopLevelBinaries); 1033 1034 for (McpEndpointDeclaration endpointDeclaration : dedupeAndOrderMcpEndpoints(newlyCollected)) 1035 merged.put(generateMcpEndpointKey(endpointDeclaration), endpointDeclaration); 1036 1037 if (pruneDeletedEnabled && classOutputRoot != null) 1038 merged.values().removeIf(endpointDeclaration -> !classFileExistsInOutputRoot(classOutputRoot, endpointDeclaration.className())); 1039 1040 List<McpEndpointDeclaration> toWrite = dedupeAndOrderMcpEndpoints(new ArrayList<>(merged.values())); 1041 1042 writeMcpIndexResource(toWrite, classOutputIndexPath, touchedTopLevelBinaries, newlyCollected); 1043 1044 if (sideCarIndexPath != null) writeMcpIndexFileAtomically(sideCarIndexPath, toWrite); 1045 if (persistentIndexPath != null) writeMcpIndexFileAtomically(persistentIndexPath, toWrite); 1046 } 1047 1048 private void removeTouchedEntries(Map<String, ResourceMethodDeclaration> merged, 1049 Set<String> touchedTopLevelBinaries) { 1050 if (touchedTopLevelBinaries == null || touchedTopLevelBinaries.isEmpty()) return; 1051 1052 merged.values().removeIf(r -> { 1053 String ownerBin = r.className(); 1054 for (String top : touchedTopLevelBinaries) { 1055 if (ownerBin.equals(top) || ownerBin.startsWith(top + "$")) return true; 1056 } 1057 return false; 1058 }); 1059 } 1060 1061 private void removeTouchedMcpEntries(Map<String, McpEndpointDeclaration> merged, 1062 Set<String> touchedTopLevelBinaries) { 1063 if (touchedTopLevelBinaries == null || touchedTopLevelBinaries.isEmpty()) return; 1064 1065 merged.values().removeIf(endpointDeclaration -> { 1066 String ownerBin = endpointDeclaration.className(); 1067 for (String top : touchedTopLevelBinaries) { 1068 if (ownerBin.equals(top) || ownerBin.startsWith(top + "$")) return true; 1069 } 1070 return false; 1071 }); 1072 } 1073 1074 private boolean readIndexFromLocation(StandardLocation location, Map<String, ResourceMethodDeclaration> out) { 1075 try { 1076 FileObject fo = filer.getResource(location, "", RESOURCE_METHOD_LOOKUP_TABLE_PATH); 1077 try (BufferedReader reader = new BufferedReader(new InputStreamReader(fo.openInputStream(), StandardCharsets.UTF_8))) { 1078 readIndexFromReader(reader, out); 1079 } 1080 return true; 1081 } catch (IOException ignored) { 1082 return false; 1083 } 1084 } 1085 1086 private boolean readMcpIndexFromLocation(StandardLocation location, Map<String, McpEndpointDeclaration> out) { 1087 try { 1088 FileObject fo = filer.getResource(location, "", MCP_ENDPOINT_LOOKUP_TABLE_PATH); 1089 try (BufferedReader reader = new BufferedReader(new InputStreamReader(fo.openInputStream(), StandardCharsets.UTF_8))) { 1090 readMcpIndexFromReader(reader, out); 1091 } 1092 return true; 1093 } catch (IOException ignored) { 1094 return false; 1095 } 1096 } 1097 1098 private boolean readIndexFromPath(Path path, Map<String, ResourceMethodDeclaration> out) { 1099 if (path == null || !Files.isRegularFile(path)) return false; 1100 try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { 1101 readIndexFromReader(reader, out); 1102 return true; 1103 } catch (IOException ignored) { 1104 return false; 1105 } 1106 } 1107 1108 private boolean readMcpIndexFromPath(Path path, Map<String, McpEndpointDeclaration> out) { 1109 if (path == null || !Files.isRegularFile(path)) return false; 1110 try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { 1111 readMcpIndexFromReader(reader, out); 1112 return true; 1113 } catch (IOException ignored) { 1114 return false; 1115 } 1116 } 1117 1118 private void readIndexFromReader(BufferedReader reader, Map<String, ResourceMethodDeclaration> out) throws IOException { 1119 String line; 1120 while ((line = reader.readLine()) != null) { 1121 line = line.trim(); 1122 if (line.isEmpty()) continue; 1123 ResourceMethodDeclaration r = parseIndexLine(line); 1124 if (r != null) out.put(generateKey(r), r); 1125 } 1126 } 1127 1128 private void readMcpIndexFromReader(BufferedReader reader, Map<String, McpEndpointDeclaration> out) throws IOException { 1129 String line; 1130 while ((line = reader.readLine()) != null) { 1131 line = line.trim(); 1132 if (line.isEmpty()) continue; 1133 McpEndpointDeclaration endpointDeclaration = parseMcpIndexLine(line); 1134 if (endpointDeclaration != null) out.put(generateMcpEndpointKey(endpointDeclaration), endpointDeclaration); 1135 } 1136 } 1137 1138 private Path findClassOutputRoot() { 1139 // Try to read an existing marker file 1140 try { 1141 FileObject fo = filer.getResource(StandardLocation.CLASS_OUTPUT, "", OUTPUT_ROOT_MARKER_PATH); 1142 Path root = outputRootFromUri(fo.toUri(), OUTPUT_ROOT_MARKER_PATH); 1143 if (root != null) return root; 1144 } catch (IOException ignored) { 1145 } 1146 1147 // Create marker to discover root 1148 try { 1149 FileObject fo = filer.createResource(StandardLocation.CLASS_OUTPUT, "", OUTPUT_ROOT_MARKER_PATH); 1150 try (Writer w = fo.openWriter()) { 1151 w.write(""); 1152 } 1153 return outputRootFromUri(fo.toUri(), OUTPUT_ROOT_MARKER_PATH); 1154 } catch (IOException ignored) { 1155 return null; 1156 } 1157 } 1158 1159 private Path sideCarIndexPath(Path classOutputRoot) { 1160 if (classOutputRoot == null) return null; 1161 Path parent = classOutputRoot.getParent(); 1162 if (parent == null) return null; 1163 String outputRootName = classOutputRoot.getFileName().toString(); 1164 return parent.resolve(SIDE_CAR_DIR_NAME).resolve(outputRootName).resolve(SIDE_CAR_INDEX_FILENAME); 1165 } 1166 1167 private Path persistentIndexPath(Path classOutputRoot) { 1168 if (classOutputRoot == null) return null; 1169 Path cacheRoot = persistentCacheRoot(); 1170 if (cacheRoot == null) return null; 1171 1172 String key = hashPath(classOutputRoot.toAbsolutePath().normalize().toString()); 1173 return cacheRoot.resolve(PERSISTENT_CACHE_INDEX_DIR).resolve(key).resolve(SIDE_CAR_INDEX_FILENAME); 1174 } 1175 1176 private Path sideCarMcpIndexPath(Path classOutputRoot) { 1177 if (classOutputRoot == null) return null; 1178 Path parent = classOutputRoot.getParent(); 1179 if (parent == null) return null; 1180 String outputRootName = classOutputRoot.getFileName().toString(); 1181 return parent.resolve(SIDE_CAR_DIR_NAME).resolve(outputRootName).resolve(MCP_SIDE_CAR_INDEX_FILENAME); 1182 } 1183 1184 private Path persistentMcpIndexPath(Path classOutputRoot) { 1185 if (classOutputRoot == null) return null; 1186 Path cacheRoot = persistentCacheRoot(); 1187 if (cacheRoot == null) return null; 1188 1189 String key = hashPath(classOutputRoot.toAbsolutePath().normalize().toString()); 1190 return cacheRoot.resolve(MCP_PERSISTENT_CACHE_INDEX_DIR).resolve(key).resolve(MCP_SIDE_CAR_INDEX_FILENAME); 1191 } 1192 1193 /** 1194 * Persistent caching is only enabled when soklet.cacheDir is explicitly set. 1195 * This avoids writing project-root ".soklet" directories by default. 1196 */ 1197 private Path persistentCacheRoot() { 1198 String override = processingEnv.getOptions().get(PROCESSOR_OPTION_CACHE_DIR); 1199 if (override == null || override.isBlank()) return null; 1200 try { 1201 return Paths.get(override); 1202 } catch (RuntimeException ignored) { 1203 return null; 1204 } 1205 } 1206 1207 private boolean classFileExistsInOutputRoot(Path root, String binaryName) { 1208 if (root == null) return true; 1209 Path classFile = root.resolve(binaryName.replace('.', '/') + ".class"); 1210 return Files.isRegularFile(classFile); 1211 } 1212 1213 private Path outputRootFromUri(URI uri, String pathSuffix) { 1214 if (uri == null || !"file".equalsIgnoreCase(uri.getScheme())) return null; 1215 Path file = Paths.get(uri); 1216 int segments = countPathSegments(pathSuffix); 1217 Path root = file; 1218 for (int i = 0; i < segments; i++) { 1219 root = root.getParent(); 1220 if (root == null) return null; 1221 } 1222 return root; 1223 } 1224 1225 private ResourceMethodDeclaration parseIndexLine(String line) { 1226 try { 1227 String[] parts = line.split("\\|", -1); 1228 if (parts.length < 6) return null; 1229 1230 HttpMethod httpMethod = HttpMethod.valueOf(parts[0]); 1231 Base64.Decoder dec = Base64.getDecoder(); 1232 1233 String path = new String(dec.decode(parts[1]), StandardCharsets.UTF_8); 1234 String className = new String(dec.decode(parts[2]), StandardCharsets.UTF_8); 1235 String methodName = new String(dec.decode(parts[3]), StandardCharsets.UTF_8); 1236 String paramsJoined = new String(dec.decode(parts[4]), StandardCharsets.UTF_8); 1237 boolean sse = Boolean.parseBoolean(parts[5]); 1238 1239 String[] paramTypes; 1240 if (paramsJoined.isEmpty()) { 1241 paramTypes = new String[0]; 1242 } else { 1243 List<String> tmp = Arrays.stream(paramsJoined.split(";")) 1244 .filter(s -> !s.isEmpty()) 1245 .collect(Collectors.toList()); 1246 paramTypes = tmp.toArray(String[]::new); 1247 } 1248 1249 return new ResourceMethodDeclaration(httpMethod, path, className, methodName, paramTypes, sse); 1250 } catch (Throwable t) { 1251 return null; 1252 } 1253 } 1254 1255 private McpEndpointDeclaration parseMcpIndexLine(String line) { 1256 try { 1257 String className = new String(Base64.getDecoder().decode(line), StandardCharsets.UTF_8); 1258 return new McpEndpointDeclaration(className); 1259 } catch (Throwable t) { 1260 return null; 1261 } 1262 } 1263 1264 /** 1265 * Writes the merged index to CLASS_OUTPUT. 1266 * Uses originating elements (best effort) so incremental build tools can track dependencies. 1267 * 1268 * <p>Fallback strategy if createResource fails: 1269 * <ol> 1270 * <li>Try opening a writer on filer.getResource(...)</li> 1271 * <li>Try direct filesystem write if classOutputIndexPath is available</li> 1272 * </ol> 1273 */ 1274 private void writeRoutesIndexResource(List<ResourceMethodDeclaration> routes, 1275 Path classOutputIndexPath, 1276 Set<String> touchedTopLevelBinaries, 1277 List<ResourceMethodDeclaration> newlyCollected) { 1278 Element[] origins = computeOriginatingElements(touchedTopLevelBinaries, newlyCollected); 1279 1280 try { 1281 FileObject fo = filer.createResource(StandardLocation.CLASS_OUTPUT, "", RESOURCE_METHOD_LOOKUP_TABLE_PATH, origins); 1282 try (Writer w = fo.openWriter()) { 1283 writeIndexToWriter(w, routes); 1284 } 1285 return; 1286 } catch (FilerException exists) { 1287 // Try writing via getResource/openWriter 1288 try { 1289 FileObject fo = filer.getResource(StandardLocation.CLASS_OUTPUT, "", RESOURCE_METHOD_LOOKUP_TABLE_PATH); 1290 try (Writer w = fo.openWriter()) { 1291 writeIndexToWriter(w, routes); 1292 } 1293 return; 1294 } catch (IOException ignored) { 1295 // Fall through to direct path write if available 1296 } 1297 } catch (IOException e) { 1298 // Fall through to direct path write if available 1299 debug("SokletProcessor: filer.createResource/openWriter failed (%s); attempting direct write.", e); 1300 } 1301 1302 // Direct path write (best effort) 1303 if (classOutputIndexPath != null) { 1304 try { 1305 writeIndexFileAtomicallyOrThrow(classOutputIndexPath, routes); 1306 return; 1307 } catch (IOException e) { 1308 throw new UncheckedIOException("Failed to write " + RESOURCE_METHOD_LOOKUP_TABLE_PATH, e); 1309 } 1310 } 1311 1312 throw new UncheckedIOException("Failed to write " + RESOURCE_METHOD_LOOKUP_TABLE_PATH, new IOException("No writable CLASS_OUTPUT path available")); 1313 } 1314 1315 private void writeMcpIndexResource(List<McpEndpointDeclaration> endpoints, 1316 Path classOutputIndexPath, 1317 Set<String> touchedTopLevelBinaries, 1318 List<McpEndpointDeclaration> newlyCollected) { 1319 Element[] origins = computeMcpOriginatingElements(touchedTopLevelBinaries, newlyCollected); 1320 1321 try { 1322 FileObject fo = filer.createResource(StandardLocation.CLASS_OUTPUT, "", MCP_ENDPOINT_LOOKUP_TABLE_PATH, origins); 1323 try (Writer w = fo.openWriter()) { 1324 writeMcpIndexToWriter(w, endpoints); 1325 } 1326 return; 1327 } catch (FilerException exists) { 1328 try { 1329 FileObject fo = filer.getResource(StandardLocation.CLASS_OUTPUT, "", MCP_ENDPOINT_LOOKUP_TABLE_PATH); 1330 try (Writer w = fo.openWriter()) { 1331 writeMcpIndexToWriter(w, endpoints); 1332 } 1333 return; 1334 } catch (IOException ignored) { 1335 // Fall through to direct path write if available 1336 } 1337 } catch (IOException e) { 1338 debug("SokletProcessor: filer.createResource/openWriter for MCP index failed (%s); attempting direct write.", e); 1339 } 1340 1341 if (classOutputIndexPath != null) { 1342 try { 1343 writeMcpIndexFileAtomicallyOrThrow(classOutputIndexPath, endpoints); 1344 return; 1345 } catch (IOException e) { 1346 throw new UncheckedIOException("Failed to write " + MCP_ENDPOINT_LOOKUP_TABLE_PATH, e); 1347 } 1348 } 1349 1350 throw new UncheckedIOException("Failed to write " + MCP_ENDPOINT_LOOKUP_TABLE_PATH, new IOException("No writable CLASS_OUTPUT path available")); 1351 } 1352 1353 private Element[] computeOriginatingElements(Set<String> touchedTopLevelBinaries, 1354 List<ResourceMethodDeclaration> newlyCollected) { 1355 Set<Element> origins = new LinkedHashSet<>(); 1356 1357 // Always include touched top-level types (these are definitely in this compilation) 1358 if (touchedTopLevelBinaries != null) { 1359 for (String top : touchedTopLevelBinaries) { 1360 TypeElement te = elements.getTypeElement(top); 1361 if (te != null) origins.add(te); 1362 } 1363 } 1364 1365 // Also include owners of newly collected routes (top-level if possible) 1366 if (newlyCollected != null) { 1367 for (ResourceMethodDeclaration r : newlyCollected) { 1368 String bin = r.className(); 1369 int dollar = bin.indexOf('$'); 1370 String top = (dollar >= 0) ? bin.substring(0, dollar) : bin; 1371 1372 TypeElement te = elements.getTypeElement(top); 1373 if (te != null) origins.add(te); 1374 } 1375 } 1376 1377 return origins.toArray(new Element[0]); 1378 } 1379 1380 private Element[] computeMcpOriginatingElements(Set<String> touchedTopLevelBinaries, 1381 List<McpEndpointDeclaration> newlyCollected) { 1382 Set<Element> origins = new LinkedHashSet<>(); 1383 1384 if (touchedTopLevelBinaries != null) { 1385 for (String top : touchedTopLevelBinaries) { 1386 TypeElement te = elements.getTypeElement(top); 1387 if (te != null) origins.add(te); 1388 } 1389 } 1390 1391 if (newlyCollected != null) { 1392 for (McpEndpointDeclaration endpointDeclaration : newlyCollected) { 1393 String bin = endpointDeclaration.className(); 1394 int dollar = bin.indexOf('$'); 1395 String top = (dollar >= 0) ? bin.substring(0, dollar) : bin; 1396 1397 TypeElement te = elements.getTypeElement(top); 1398 if (te != null) origins.add(te); 1399 } 1400 } 1401 1402 return origins.toArray(new Element[0]); 1403 } 1404 1405 private void writeIndexToWriter(Writer w, List<ResourceMethodDeclaration> routes) throws IOException { 1406 Base64.Encoder b64 = Base64.getEncoder(); 1407 for (ResourceMethodDeclaration r : routes) { 1408 String params = String.join(";", r.parameterTypes()); 1409 String line = String.join("|", 1410 r.httpMethod().name(), 1411 b64encode(b64, r.path()), 1412 b64encode(b64, r.className()), 1413 b64encode(b64, r.methodName()), 1414 b64encode(b64, params), 1415 Boolean.toString(r.sseEventSource()) 1416 ); 1417 w.write(line); 1418 w.write('\n'); 1419 } 1420 } 1421 1422 private void writeMcpIndexToWriter(Writer w, List<McpEndpointDeclaration> endpoints) throws IOException { 1423 Base64.Encoder b64 = Base64.getEncoder(); 1424 for (McpEndpointDeclaration endpointDeclaration : endpoints) { 1425 w.write(b64encode(b64, endpointDeclaration.className())); 1426 w.write('\n'); 1427 } 1428 } 1429 1430 /** 1431 * Best-effort atomic write. Failures are logged (if debug enabled) and ignored. 1432 */ 1433 private void writeIndexFileAtomically(Path target, List<ResourceMethodDeclaration> routes) { 1434 if (target == null) return; 1435 try { 1436 writeIndexFileAtomicallyOrThrow(target, routes); 1437 } catch (IOException e) { 1438 debug("SokletProcessor: failed to write cache index %s (%s)", target, e); 1439 } 1440 } 1441 1442 private void writeMcpIndexFileAtomically(Path target, List<McpEndpointDeclaration> endpoints) { 1443 if (target == null) return; 1444 try { 1445 writeMcpIndexFileAtomicallyOrThrow(target, endpoints); 1446 } catch (IOException e) { 1447 debug("SokletProcessor: failed to write MCP cache index %s (%s)", target, e); 1448 } 1449 } 1450 1451 private void writeIndexFileAtomicallyOrThrow(Path target, List<ResourceMethodDeclaration> routes) throws IOException { 1452 Path parent = target.getParent(); 1453 if (parent != null) Files.createDirectories(parent); 1454 1455 // temp file in same dir so move is atomic on most filesystems 1456 Path tmp = Files.createTempFile(parent, target.getFileName().toString(), ".tmp"); 1457 try (Writer w = Files.newBufferedWriter(tmp, StandardCharsets.UTF_8)) { 1458 writeIndexToWriter(w, routes); 1459 } 1460 1461 try { 1462 Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); 1463 } catch (AtomicMoveNotSupportedException e) { 1464 Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); 1465 } 1466 } 1467 1468 private void writeMcpIndexFileAtomicallyOrThrow(Path target, List<McpEndpointDeclaration> endpoints) throws IOException { 1469 Path parent = target.getParent(); 1470 if (parent != null) Files.createDirectories(parent); 1471 1472 Path tmp = Files.createTempFile(parent, target.getFileName().toString(), ".tmp"); 1473 try (Writer w = Files.newBufferedWriter(tmp, StandardCharsets.UTF_8)) { 1474 writeMcpIndexToWriter(w, endpoints); 1475 } 1476 1477 try { 1478 Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); 1479 } catch (AtomicMoveNotSupportedException e) { 1480 Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); 1481 } 1482 } 1483 1484 private static String b64encode(Base64.Encoder enc, String s) { 1485 byte[] bytes = (s == null ? new byte[0] : s.getBytes(StandardCharsets.UTF_8)); 1486 return enc.encodeToString(bytes); 1487 } 1488 1489 // ---- Messaging ------------------------------------------------------------ 1490 1491 private void error(Element e, String fmt, Object... args) { 1492 messager.printMessage(Diagnostic.Kind.ERROR, String.format(fmt, args), e); 1493 } 1494 1495 private void debug(String fmt, Object... args) { 1496 if (!debugEnabled) return; 1497 messager.printMessage(Diagnostic.Kind.NOTE, String.format(fmt, args)); 1498 } 1499 1500 // ---- Misc helpers --------------------------------------------------------- 1501 1502 private static CacheMode parseCacheMode(String option) { 1503 if (option == null || option.isBlank()) return CacheMode.SIDECAR; 1504 1505 String normalized = option.trim().toLowerCase(Locale.ROOT); 1506 switch (normalized) { 1507 case "none": 1508 case "off": 1509 case "false": 1510 return CacheMode.NONE; 1511 case "sidecar": 1512 return CacheMode.SIDECAR; 1513 case "persistent": 1514 case "persist": 1515 return CacheMode.PERSISTENT; 1516 default: 1517 // Unknown -> default to sidecar for safety 1518 return CacheMode.SIDECAR; 1519 } 1520 } 1521 1522 private static boolean parseBooleanishOption(String option) { 1523 if (option == null) return false; 1524 String normalized = option.trim(); 1525 if (normalized.isEmpty()) return false; 1526 return !"false".equalsIgnoreCase(normalized); 1527 } 1528 1529 private static String hashPath(String input) { 1530 try { 1531 MessageDigest digest = MessageDigest.getInstance("SHA-1"); 1532 byte[] bytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); 1533 return toHex(bytes); 1534 } catch (NoSuchAlgorithmException e) { 1535 return Integer.toHexString(input.hashCode()); 1536 } 1537 } 1538 1539 private static String toHex(byte[] bytes) { 1540 char[] out = new char[bytes.length * 2]; 1541 char[] digits = "0123456789abcdef".toCharArray(); 1542 for (int i = 0; i < bytes.length; i++) { 1543 int v = bytes[i] & 0xFF; 1544 out[i * 2] = digits[v >>> 4]; 1545 out[i * 2 + 1] = digits[v & 0x0F]; 1546 } 1547 return new String(out); 1548 } 1549 1550 private static int countPathSegments(String path) { 1551 int count = 1; 1552 for (int i = 0; i < path.length(); i++) { 1553 if (path.charAt(i) == '/') count++; 1554 } 1555 return count; 1556 } 1557 1558 private static String generateKey(ResourceMethodDeclaration r) { 1559 return r.httpMethod().name() + "|" + r.path() + "|" + r.className() + "|" + 1560 r.methodName() + "|" + String.join(";", r.parameterTypes()) + "|" + 1561 r.sseEventSource(); 1562 } 1563 1564 private static String generateMcpEndpointKey(McpEndpointDeclaration endpointDeclaration) { 1565 return endpointDeclaration.className(); 1566 } 1567 1568 private static List<ResourceMethodDeclaration> dedupeAndOrder(List<ResourceMethodDeclaration> in) { 1569 Map<String, ResourceMethodDeclaration> byKey = new LinkedHashMap<>(); 1570 for (ResourceMethodDeclaration r : in) byKey.putIfAbsent(generateKey(r), r); 1571 1572 List<ResourceMethodDeclaration> out = new ArrayList<>(byKey.values()); 1573 out.sort(Comparator 1574 .comparing((ResourceMethodDeclaration r) -> r.httpMethod().name()) 1575 .thenComparing(ResourceMethodDeclaration::path) 1576 .thenComparing(ResourceMethodDeclaration::className) 1577 .thenComparing(ResourceMethodDeclaration::methodName)); 1578 return out; 1579 } 1580 1581 private static List<McpEndpointDeclaration> dedupeAndOrderMcpEndpoints(List<McpEndpointDeclaration> in) { 1582 Map<String, McpEndpointDeclaration> byKey = new LinkedHashMap<>(); 1583 for (McpEndpointDeclaration endpointDeclaration : in) 1584 byKey.putIfAbsent(generateMcpEndpointKey(endpointDeclaration), endpointDeclaration); 1585 1586 List<McpEndpointDeclaration> out = new ArrayList<>(byKey.values()); 1587 out.sort(Comparator.comparing(McpEndpointDeclaration::className)); 1588 return out; 1589 } 1590 1591 private record ResourceMethodSpecificityKey(HttpMethod httpMethod, 1592 Boolean sseEventSource, 1593 Boolean hasVarargs, 1594 Long placeholderCount, 1595 Long literalCount) {} 1596 1597 private record McpEndpointDeclaration(String className) {} 1598}