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