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