001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *   http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing,
013 * software distributed under the License is distributed on an
014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015 * KIND, either express or implied.  See the License for the
016 * specific language governing permissions and limitations
017 * under the License.
018 */
019package org.eclipse.aether.tools;
020
021import java.io.IOException;
022import java.io.PrintWriter;
023import java.io.StringWriter;
024import java.io.UncheckedIOException;
025import java.io.Writer;
026import java.nio.charset.StandardCharsets;
027import java.nio.file.Files;
028import java.nio.file.Path;
029import java.nio.file.Paths;
030import java.util.ArrayList;
031import java.util.Comparator;
032import java.util.HashMap;
033import java.util.List;
034import java.util.Map;
035import java.util.Properties;
036import java.util.concurrent.Callable;
037import java.util.regex.Matcher;
038import java.util.regex.Pattern;
039import java.util.spi.ToolProvider;
040import java.util.stream.Stream;
041
042import org.apache.velocity.VelocityContext;
043import org.apache.velocity.app.VelocityEngine;
044import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader;
045import org.codehaus.plexus.util.io.CachingWriter;
046import org.jboss.forge.roaster.Roaster;
047import org.jboss.forge.roaster._shade.org.eclipse.jdt.core.dom.AST;
048import org.jboss.forge.roaster._shade.org.eclipse.jdt.core.dom.ASTNode;
049import org.jboss.forge.roaster._shade.org.eclipse.jdt.core.dom.Javadoc;
050import org.jboss.forge.roaster.model.JavaDoc;
051import org.jboss.forge.roaster.model.JavaDocCapable;
052import org.jboss.forge.roaster.model.JavaDocTag;
053import org.jboss.forge.roaster.model.JavaType;
054import org.jboss.forge.roaster.model.impl.JavaDocImpl;
055import org.jboss.forge.roaster.model.source.FieldSource;
056import org.jboss.forge.roaster.model.source.JavaClassSource;
057import org.jboss.forge.roaster.model.source.JavaDocSource;
058import org.objectweb.asm.AnnotationVisitor;
059import org.objectweb.asm.ClassReader;
060import org.objectweb.asm.ClassVisitor;
061import org.objectweb.asm.FieldVisitor;
062import org.objectweb.asm.Opcodes;
063import picocli.CommandLine;
064
065@CommandLine.Command(name = "docgen", description = "Maven Documentation Generator")
066public class CollectConfiguration implements Callable<Integer> {
067    public static void main(String[] args) {
068        new CommandLine(new CollectConfiguration()).execute(args);
069    }
070
071    protected static final String KEY = "key";
072
073    public enum Mode {
074        maven,
075        resolver
076    }
077
078    @CommandLine.Option(
079            names = {"-m", "--mode"},
080            arity = "1",
081            paramLabel = "mode",
082            description = "The mode of generator (what is being scanned?), supported modes are 'maven', 'resolver'")
083    protected Mode mode;
084
085    @CommandLine.Option(
086            names = {"-t", "--templates"},
087            arity = "1",
088            split = ",",
089            paramLabel = "template",
090            description = "The template names to write content out without '.vm' extension")
091    protected List<String> templates;
092
093    @CommandLine.Parameters(index = "0", description = "The root directory to process sources from")
094    protected Path rootDirectory;
095
096    @CommandLine.Parameters(index = "1", description = "The directory to generate output(s) to")
097    protected Path outputDirectory;
098
099    @Override
100    public Integer call() {
101        try {
102            rootDirectory = rootDirectory.toAbsolutePath().normalize();
103            outputDirectory = outputDirectory.toAbsolutePath().normalize();
104
105            ArrayList<Map<String, String>> discoveredKeys = new ArrayList<>();
106            try (Stream<Path> stream = Files.walk(rootDirectory)) {
107                if (mode == Mode.maven) {
108                    System.out.println("Processing Maven sources from " + rootDirectory);
109                    stream.map(Path::toAbsolutePath)
110                            .filter(p -> p.getFileName().toString().endsWith(".class"))
111                            .filter(p -> p.toString().contains("/target/classes/"))
112                            .forEach(p -> {
113                                processMavenClass(p, discoveredKeys);
114                            });
115                } else if (mode == Mode.resolver) {
116                    System.out.println("Processing Resolver sources from " + rootDirectory);
117                    stream.map(Path::toAbsolutePath)
118                            .filter(p -> p.getFileName().toString().endsWith(".java"))
119                            .filter(p -> p.toString().contains("/src/main/java/"))
120                            .filter(p -> !p.toString().endsWith("/module-info.java"))
121                            .forEach(p -> processResolverClass(p, discoveredKeys));
122                } else {
123                    throw new IllegalStateException("Unsupported mode " + mode);
124                }
125            }
126
127            discoveredKeys.sort(Comparator.comparing(e -> e.get(KEY)));
128
129            Properties properties = new Properties();
130            properties.setProperty("resource.loaders", "classpath");
131            properties.setProperty("resource.loader.classpath.class", ClasspathResourceLoader.class.getName());
132            VelocityEngine velocityEngine = new VelocityEngine();
133            velocityEngine.init(properties);
134
135            VelocityContext context = new VelocityContext();
136            context.put("keys", discoveredKeys);
137
138            for (String template : templates) {
139                Path output = outputDirectory.resolve(template);
140                Files.createDirectories(output.getParent());
141                System.out.println("Writing out to " + output);
142                try (Writer fileWriter = new CachingWriter(output, StandardCharsets.UTF_8)) {
143                    velocityEngine.getTemplate(template + ".vm").merge(context, fileWriter);
144                }
145            }
146            return 0;
147        } catch (Exception e) {
148            e.printStackTrace(System.err);
149            return 1;
150        }
151    }
152
153    protected void processMavenClass(Path path, List<Map<String, String>> discoveredKeys) {
154        try {
155            ClassReader classReader = new ClassReader(Files.newInputStream(path));
156            classReader.accept(
157                    new ClassVisitor(Opcodes.ASM9) {
158                        @Override
159                        public FieldVisitor visitField(
160                                int fieldAccess,
161                                String fieldName,
162                                String fieldDescriptor,
163                                String fieldSignature,
164                                Object fieldValue) {
165                            return new FieldVisitor(Opcodes.ASM9) {
166                                @Override
167                                public AnnotationVisitor visitAnnotation(
168                                        String annotationDescriptor, boolean annotationVisible) {
169                                    if (annotationDescriptor.equals("Lorg/apache/maven/api/annotations/Config;")) {
170                                        return new AnnotationVisitor(Opcodes.ASM9) {
171                                            final Map<String, Object> values = new HashMap<>();
172
173                                            @Override
174                                            public void visit(String name, Object value) {
175                                                values.put(name, value);
176                                            }
177
178                                            @Override
179                                            public void visitEnum(String name, String descriptor, String value) {
180                                                values.put(name, value);
181                                            }
182
183                                            @Override
184                                            public void visitEnd() {
185                                                JavaType<?> jtype = parse(Paths.get(path.toString()
186                                                        .replace("/target/classes/", "/src/main/java/")
187                                                        .replace(".class", ".java")));
188                                                FieldSource<JavaClassSource> f =
189                                                        ((JavaClassSource) jtype).getField(fieldName);
190
191                                                String fqName = null;
192                                                String desc = cloneJavadoc(f.getJavaDoc())
193                                                        .removeAllTags()
194                                                        .getFullText()
195                                                        .replace("*", "\\*");
196                                                String since = getSince(f);
197                                                String source = (values.get("source") != null
198                                                                ? (String) values.get("source")
199                                                                : "USER_PROPERTIES") // TODO: enum
200                                                        .toLowerCase();
201                                                source = switch (source) {
202                                                    case "model" -> "Model properties";
203                                                    case "user_properties" -> "User properties";
204                                                    default -> source;
205                                                };
206                                                String type = (values.get("type") != null
207                                                        ? (String) values.get("type")
208                                                        : "java.lang.String");
209                                                if (type.startsWith("java.lang.")) {
210                                                    type = type.substring("java.lang.".length());
211                                                } else if (type.startsWith("java.util.")) {
212                                                    type = type.substring("java.util.".length());
213                                                }
214                                                discoveredKeys.add(Map.of(
215                                                        KEY,
216                                                        fieldValue.toString(),
217                                                        "defaultValue",
218                                                        values.get("defaultValue") != null
219                                                                ? values.get("defaultValue")
220                                                                        .toString()
221                                                                : "",
222                                                        "fqName",
223                                                        nvl(fqName, ""),
224                                                        "description",
225                                                        desc,
226                                                        "since",
227                                                        nvl(since, ""),
228                                                        "configurationSource",
229                                                        source,
230                                                        "configurationType",
231                                                        type));
232                                            }
233                                        };
234                                    }
235                                    return null;
236                                }
237                            };
238                        }
239                    },
240                    0);
241        } catch (IOException e) {
242            throw new RuntimeException(e);
243        }
244    }
245
246    protected void processResolverClass(Path path, List<Map<String, String>> discoveredKeys) {
247        JavaType<?> type = parse(path);
248        if (type instanceof JavaClassSource javaClassSource) {
249            javaClassSource.getFields().stream()
250                    .filter(this::hasConfigurationSource)
251                    .forEach(f -> {
252                        Map<String, String> constants = extractConstants(Paths.get(path.toString()
253                                .replace("/src/main/java/", "/target/classes/")
254                                .replace(".java", ".class")));
255
256                        String name = f.getName();
257                        String key = constants.get(name);
258                        String fqName = f.getOrigin().getCanonicalName() + "." + name;
259                        String configurationType = getConfigurationType(f);
260                        String defValue = getTag(f, "@configurationDefaultValue");
261                        if (defValue != null && defValue.startsWith("{@link #") && defValue.endsWith("}")) {
262                            // constant "lookup"
263                            String lookupValue = constants.get(defValue.substring(8, defValue.length() - 1));
264                            if (lookupValue == null) {
265                                // currently we hard fail if javadoc cannot be looked up
266                                // workaround: at cost of redundancy, but declare constants in situ for now
267                                // (in same class)
268                                throw new IllegalArgumentException(
269                                        "Could not look up " + defValue + " for configuration " + fqName);
270                            }
271                            defValue = lookupValue;
272                            if ("java.lang.Long".equals(configurationType)
273                                    && (defValue.endsWith("l") || defValue.endsWith("L"))) {
274                                defValue = defValue.substring(0, defValue.length() - 1);
275                            }
276                        }
277                        discoveredKeys.add(Map.of(
278                                KEY,
279                                key,
280                                "defaultValue",
281                                nvl(defValue, ""),
282                                "fqName",
283                                fqName,
284                                "description",
285                                cleanseJavadoc(f),
286                                "since",
287                                nvl(getSince(f), ""),
288                                "configurationSource",
289                                getConfigurationSource(f),
290                                "configurationType",
291                                configurationType,
292                                "supportRepoIdSuffix",
293                                toYesNo(getTag(f, "@configurationRepoIdSuffix"))));
294                    });
295        }
296    }
297
298    protected JavaDocSource<Object> cloneJavadoc(JavaDocSource<?> javaDoc) {
299        Javadoc jd = (Javadoc) javaDoc.getInternal();
300        return new JavaDocImpl<>(javaDoc.getOrigin(), (Javadoc)
301                ASTNode.copySubtree(AST.newAST(jd.getAST().apiLevel(), false), jd));
302    }
303
304    protected String cleanseJavadoc(FieldSource<JavaClassSource> javaClassSource) {
305        JavaDoc<FieldSource<JavaClassSource>> javaDoc = javaClassSource.getJavaDoc();
306        String[] text = javaDoc.getFullText().split("\n");
307        StringBuilder result = new StringBuilder();
308        for (String line : text) {
309            if (!line.startsWith("@") && !line.trim().isEmpty()) {
310                result.append(line);
311            }
312        }
313        return cleanseTags(result.toString());
314    }
315
316    protected String cleanseTags(String text) {
317        // {@code XXX} -> <pre>XXX</pre>
318        // {@link XXX} -> ??? pre for now
319        Pattern pattern = Pattern.compile("(\\{@\\w\\w\\w\\w (.+?)})");
320        Matcher matcher = pattern.matcher(text);
321        if (!matcher.find()) {
322            return text;
323        }
324        int prevEnd = 0;
325        StringBuilder result = new StringBuilder();
326        do {
327            result.append(text, prevEnd, matcher.start(1));
328            result.append("<code>");
329            result.append(matcher.group(2));
330            result.append("</code>");
331            prevEnd = matcher.end(1);
332        } while (matcher.find());
333        result.append(text, prevEnd, text.length());
334        return result.toString();
335    }
336
337    protected JavaType<?> parse(Path path) {
338        try {
339            return Roaster.parse(path.toFile());
340        } catch (IOException e) {
341            throw new UncheckedIOException(e);
342        }
343    }
344
345    protected String toYesNo(String value) {
346        return "yes".equalsIgnoreCase(value) || "true".equalsIgnoreCase(value) ? "Yes" : "No";
347    }
348
349    protected String nvl(String string, String def) {
350        return string == null ? def : string;
351    }
352
353    protected boolean hasConfigurationSource(JavaDocCapable<?> javaDocCapable) {
354        return getTag(javaDocCapable, "@configurationSource") != null;
355    }
356
357    protected String getConfigurationType(JavaDocCapable<?> javaDocCapable) {
358        String type = getTag(javaDocCapable, "@configurationType");
359        if (type != null) {
360            String linkPrefix = "{@link ";
361            String linkSuffix = "}";
362            if (type.startsWith(linkPrefix) && type.endsWith(linkSuffix)) {
363                type = type.substring(linkPrefix.length(), type.length() - linkSuffix.length());
364            }
365            String javaLangPackage = "java.lang.";
366            if (type.startsWith(javaLangPackage)) {
367                type = type.substring(javaLangPackage.length());
368            }
369        }
370        return nvl(type, "n/a");
371    }
372
373    protected String getConfigurationSource(JavaDocCapable<?> javaDocCapable) {
374        String source = getTag(javaDocCapable, "@configurationSource");
375        if ("{@link RepositorySystemSession#getConfigProperties()}".equals(source)) {
376            return "Session Configuration";
377        } else if ("{@link System#getProperty(String,String)}".equals(source)) {
378            return "Java System Properties";
379        } else {
380            return source;
381        }
382    }
383
384    protected String getSince(JavaDocCapable<?> javaDocCapable) {
385        List<JavaDocTag> tags;
386        if (javaDocCapable != null) {
387            if (javaDocCapable instanceof FieldSource<?> fieldSource) {
388                tags = fieldSource.getJavaDoc().getTags("@since");
389                if (tags.isEmpty()) {
390                    return getSince(fieldSource.getOrigin());
391                } else {
392                    return tags.get(0).getValue();
393                }
394            } else if (javaDocCapable instanceof JavaClassSource classSource) {
395                tags = classSource.getJavaDoc().getTags("@since");
396                if (!tags.isEmpty()) {
397                    return tags.get(0).getValue();
398                }
399            }
400        }
401        return null;
402    }
403
404    protected String getTag(JavaDocCapable<?> javaDocCapable, String tagName) {
405        List<JavaDocTag> tags;
406        if (javaDocCapable != null) {
407            if (javaDocCapable instanceof FieldSource<?> fieldSource) {
408                tags = fieldSource.getJavaDoc().getTags(tagName);
409                if (tags.isEmpty()) {
410                    return getTag(fieldSource.getOrigin(), tagName);
411                } else {
412                    return tags.get(0).getValue();
413                }
414            }
415        }
416        return null;
417    }
418
419    protected static final Pattern CONSTANT_PATTERN = Pattern.compile(".*static final.* ([A-Z_]+) = (.*);");
420
421    protected static final ToolProvider JAVAP = ToolProvider.findFirst("javap").orElseThrow();
422
423    /**
424     * Builds "constant table" for one single class.
425     * <p>
426     * Limitations:
427     * - works only for single class (no inherited constants)
428     * - does not work for fields that are Enum.name()
429     * - more to come
430     */
431    protected static Map<String, String> extractConstants(Path file) {
432        StringWriter out = new StringWriter();
433        JAVAP.run(new PrintWriter(out), new PrintWriter(System.err), "-constants", file.toString());
434        Map<String, String> result = new HashMap<>();
435        out.getBuffer().toString().lines().forEach(l -> {
436            Matcher matcher = CONSTANT_PATTERN.matcher(l);
437            if (matcher.matches()) {
438                result.put(matcher.group(1), matcher.group(2));
439            }
440        });
441        return result;
442    }
443}