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 -> processMavenClass(p, discoveredKeys));
113                } else if (mode == Mode.resolver) {
114                    System.out.println("Processing Resolver sources from " + rootDirectory);
115                    stream.map(Path::toAbsolutePath)
116                            .filter(p -> p.getFileName().toString().endsWith(".java"))
117                            .filter(p -> p.toString().contains("/src/main/java/"))
118                            .filter(p -> !p.toString().endsWith("/module-info.java"))
119                            .forEach(p -> processResolverClass(p, discoveredKeys));
120                } else {
121                    throw new IllegalStateException("Unsupported mode " + mode);
122                }
123            }
124
125            discoveredKeys.sort(Comparator.comparing(e -> e.get(KEY)));
126
127            Properties properties = new Properties();
128            properties.setProperty("resource.loaders", "classpath");
129            properties.setProperty("resource.loader.classpath.class", ClasspathResourceLoader.class.getName());
130            VelocityEngine velocityEngine = new VelocityEngine();
131            velocityEngine.init(properties);
132
133            VelocityContext context = new VelocityContext();
134            context.put("keys", discoveredKeys);
135
136            for (String template : templates) {
137                Path output = outputDirectory.resolve(template);
138                Files.createDirectories(output.getParent());
139                System.out.println("Writing out to " + output);
140                try (Writer fileWriter = new CachingWriter(output, StandardCharsets.UTF_8)) {
141                    velocityEngine.getTemplate(template + ".vm").merge(context, fileWriter);
142                }
143            }
144            return 0;
145        } catch (Exception e) {
146            e.printStackTrace(System.err);
147            return 1;
148        }
149    }
150
151    protected void processMavenClass(Path path, List<Map<String, String>> discoveredKeys) {
152        try {
153            ClassReader classReader = new ClassReader(Files.newInputStream(path));
154            classReader.accept(
155                    new ClassVisitor(Opcodes.ASM9) {
156                        @Override
157                        public FieldVisitor visitField(
158                                int fieldAccess,
159                                String fieldName,
160                                String fieldDescriptor,
161                                String fieldSignature,
162                                Object fieldValue) {
163                            return new FieldVisitor(Opcodes.ASM9) {
164                                @Override
165                                public AnnotationVisitor visitAnnotation(
166                                        String annotationDescriptor, boolean annotationVisible) {
167                                    if (annotationDescriptor.equals("Lorg/apache/maven/api/annotations/Config;")) {
168                                        return new AnnotationVisitor(Opcodes.ASM9) {
169                                            final Map<String, Object> values = new HashMap<>();
170
171                                            @Override
172                                            public void visit(String name, Object value) {
173                                                values.put(name, value);
174                                            }
175
176                                            @Override
177                                            public void visitEnum(String name, String descriptor, String value) {
178                                                values.put(name, value);
179                                            }
180
181                                            @Override
182                                            public void visitEnd() {
183                                                JavaType<?> jtype = parse(Paths.get(path.toString()
184                                                        .replace("/target/classes/", "/src/main/java/")
185                                                        .replace(".class", ".java")));
186                                                FieldSource<JavaClassSource> f =
187                                                        ((JavaClassSource) jtype).getField(fieldName);
188
189                                                String fqName = null;
190                                                String desc = cloneJavadoc(f.getJavaDoc())
191                                                        .removeAllTags()
192                                                        .getFullText()
193                                                        .replace("*", "\\*");
194                                                String since = getSince(f);
195                                                String source = (values.get("source") != null
196                                                                ? (String) values.get("source")
197                                                                : "USER_PROPERTIES") // TODO: enum
198                                                        .toLowerCase();
199                                                source = switch (source) {
200                                                    case "model" -> "Model properties";
201                                                    case "user_properties" -> "User properties";
202                                                    default -> source;
203                                                };
204                                                String type = (values.get("type") != null
205                                                        ? (String) values.get("type")
206                                                        : "java.lang.String");
207                                                if (type.startsWith("java.lang.")) {
208                                                    type = type.substring("java.lang.".length());
209                                                } else if (type.startsWith("java.util.")) {
210                                                    type = type.substring("java.util.".length());
211                                                }
212                                                discoveredKeys.add(Map.of(
213                                                        KEY,
214                                                        fieldValue.toString(),
215                                                        "defaultValue",
216                                                        values.get("defaultValue") != null
217                                                                ? values.get("defaultValue")
218                                                                        .toString()
219                                                                : "",
220                                                        "fqName",
221                                                        nvl(fqName, ""),
222                                                        "description",
223                                                        desc,
224                                                        "since",
225                                                        nvl(since, ""),
226                                                        "configurationSource",
227                                                        source,
228                                                        "configurationType",
229                                                        type));
230                                            }
231                                        };
232                                    }
233                                    return null;
234                                }
235                            };
236                        }
237                    },
238                    0);
239        } catch (IOException e) {
240            throw new RuntimeException(e);
241        }
242    }
243
244    protected void processResolverClass(Path path, List<Map<String, String>> discoveredKeys) {
245        JavaType<?> type = parse(path);
246        if (type instanceof JavaClassSource javaClassSource) {
247            javaClassSource.getFields().stream()
248                    .filter(this::hasConfigurationSource)
249                    .forEach(f -> {
250                        Map<String, String> constants = extractConstants(Paths.get(path.toString()
251                                .replace("/src/main/java/", "/target/classes/")
252                                .replace(".java", ".class")));
253
254                        String name = f.getName();
255                        String key = constants.get(name);
256                        String fqName = f.getOrigin().getCanonicalName() + "." + name;
257                        String configurationType = getConfigurationType(f);
258                        String defValue = getTag(f, "@configurationDefaultValue");
259                        if (defValue != null && defValue.startsWith("{@link #") && defValue.endsWith("}")) {
260                            // constant "lookup"
261                            String lookupValue = constants.get(defValue.substring(8, defValue.length() - 1));
262                            if (lookupValue == null) {
263                                // currently we hard fail if javadoc cannot be looked up
264                                // workaround: at cost of redundancy, but declare constants in situ for now
265                                // (in same class)
266                                throw new IllegalArgumentException(
267                                        "Could not look up " + defValue + " for configuration " + fqName);
268                            }
269                            defValue = lookupValue;
270                            if ("java.lang.Long".equals(configurationType)
271                                    && (defValue.endsWith("l") || defValue.endsWith("L"))) {
272                                defValue = defValue.substring(0, defValue.length() - 1);
273                            }
274                        }
275                        discoveredKeys.add(Map.of(
276                                KEY,
277                                key,
278                                "defaultValue",
279                                nvl(defValue, ""),
280                                "fqName",
281                                fqName,
282                                "description",
283                                cleanseJavadoc(f),
284                                "since",
285                                nvl(getSince(f), ""),
286                                "configurationSource",
287                                getConfigurationSource(f),
288                                "configurationType",
289                                configurationType,
290                                "supportRepoIdSuffix",
291                                toYesNo(getTag(f, "@configurationRepoIdSuffix"))));
292                    });
293        }
294    }
295
296    protected JavaDocSource<Object> cloneJavadoc(JavaDocSource<?> javaDoc) {
297        Javadoc jd = (Javadoc) javaDoc.getInternal();
298        return new JavaDocImpl<>(javaDoc.getOrigin(), (Javadoc)
299                ASTNode.copySubtree(AST.newAST(jd.getAST().apiLevel(), false), jd));
300    }
301
302    protected String cleanseJavadoc(FieldSource<JavaClassSource> javaClassSource) {
303        JavaDoc<FieldSource<JavaClassSource>> javaDoc = javaClassSource.getJavaDoc();
304        String[] text = javaDoc.getFullText().split("\n");
305        StringBuilder result = new StringBuilder();
306        for (String line : text) {
307            if (!line.startsWith("@") && !line.trim().isEmpty()) {
308                result.append(line);
309            }
310        }
311        return cleanseTags(result.toString());
312    }
313
314    protected String cleanseTags(String text) {
315        // {@code XXX} -> <pre>XXX</pre>
316        // {@link XXX} -> ??? pre for now
317        Pattern pattern = Pattern.compile("(\\{@\\w\\w\\w\\w (.+?)})");
318        Matcher matcher = pattern.matcher(text);
319        if (!matcher.find()) {
320            return text;
321        }
322        int prevEnd = 0;
323        StringBuilder result = new StringBuilder();
324        do {
325            result.append(text, prevEnd, matcher.start(1));
326            result.append("<code>");
327            result.append(matcher.group(2));
328            result.append("</code>");
329            prevEnd = matcher.end(1);
330        } while (matcher.find());
331        result.append(text, prevEnd, text.length());
332        return result.toString();
333    }
334
335    protected JavaType<?> parse(Path path) {
336        try {
337            return Roaster.parse(path.toFile());
338        } catch (IOException e) {
339            throw new UncheckedIOException(e);
340        }
341    }
342
343    protected String toYesNo(String value) {
344        return "yes".equalsIgnoreCase(value) || "true".equalsIgnoreCase(value) ? "Yes" : "No";
345    }
346
347    protected String nvl(String string, String def) {
348        return string == null ? def : string;
349    }
350
351    protected boolean hasConfigurationSource(JavaDocCapable<?> javaDocCapable) {
352        return getTag(javaDocCapable, "@configurationSource") != null;
353    }
354
355    protected String getConfigurationType(JavaDocCapable<?> javaDocCapable) {
356        String type = getTag(javaDocCapable, "@configurationType");
357        if (type != null) {
358            String linkPrefix = "{@link ";
359            String linkSuffix = "}";
360            if (type.startsWith(linkPrefix) && type.endsWith(linkSuffix)) {
361                type = type.substring(linkPrefix.length(), type.length() - linkSuffix.length());
362            }
363            String javaLangPackage = "java.lang.";
364            if (type.startsWith(javaLangPackage)) {
365                type = type.substring(javaLangPackage.length());
366            }
367        }
368        return nvl(type, "n/a");
369    }
370
371    protected String getConfigurationSource(JavaDocCapable<?> javaDocCapable) {
372        String source = getTag(javaDocCapable, "@configurationSource");
373        if ("{@link RepositorySystemSession#getConfigProperties()}".equals(source)) {
374            return "Session Configuration";
375        } else if ("{@link System#getProperty(String,String)}".equals(source)) {
376            return "Java System Properties";
377        } else {
378            return source;
379        }
380    }
381
382    protected String getSince(JavaDocCapable<?> javaDocCapable) {
383        List<JavaDocTag> tags;
384        if (javaDocCapable != null) {
385            if (javaDocCapable instanceof FieldSource<?> fieldSource) {
386                tags = fieldSource.getJavaDoc().getTags("@since");
387                if (tags.isEmpty()) {
388                    return getSince(fieldSource.getOrigin());
389                } else {
390                    return tags.get(0).getValue();
391                }
392            } else if (javaDocCapable instanceof JavaClassSource classSource) {
393                tags = classSource.getJavaDoc().getTags("@since");
394                if (!tags.isEmpty()) {
395                    return tags.get(0).getValue();
396                }
397            }
398        }
399        return null;
400    }
401
402    protected String getTag(JavaDocCapable<?> javaDocCapable, String tagName) {
403        List<JavaDocTag> tags;
404        if (javaDocCapable != null) {
405            if (javaDocCapable instanceof FieldSource<?> fieldSource) {
406                tags = fieldSource.getJavaDoc().getTags(tagName);
407                if (tags.isEmpty()) {
408                    return getTag(fieldSource.getOrigin(), tagName);
409                } else {
410                    return tags.get(0).getValue();
411                }
412            }
413        }
414        return null;
415    }
416
417    protected static final Pattern CONSTANT_PATTERN = Pattern.compile(".*static final.* ([A-Z_]+) = (.*);");
418
419    protected static final ToolProvider JAVAP = ToolProvider.findFirst("javap").orElseThrow();
420
421    /**
422     * Builds "constant table" for one single class.
423     * <p>
424     * Limitations:
425     * - works only for single class (no inherited constants)
426     * - does not work for fields that are Enum.name()
427     * - more to come
428     */
429    protected static Map<String, String> extractConstants(Path file) {
430        StringWriter out = new StringWriter();
431        JAVAP.run(new PrintWriter(out), new PrintWriter(System.err), "-constants", file.toString());
432        Map<String, String> result = new HashMap<>();
433        out.getBuffer().toString().lines().forEach(l -> {
434            Matcher matcher = CONSTANT_PATTERN.matcher(l);
435            if (matcher.matches()) {
436                result.put(matcher.group(1), matcher.group(2));
437            }
438        });
439        return result;
440    }
441}