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