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