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