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.Collections;
032import java.util.Comparator;
033import java.util.HashMap;
034import java.util.List;
035import java.util.Map;
036import java.util.Properties;
037import java.util.concurrent.Callable;
038import java.util.regex.Matcher;
039import java.util.regex.Pattern;
040import java.util.spi.ToolProvider;
041import java.util.stream.Stream;
042
043import org.apache.velocity.VelocityContext;
044import org.apache.velocity.app.VelocityEngine;
045import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader;
046import org.codehaus.plexus.util.io.CachingWriter;
047import org.jboss.forge.roaster.Roaster;
048import org.jboss.forge.roaster._shade.org.eclipse.jdt.core.dom.AST;
049import org.jboss.forge.roaster._shade.org.eclipse.jdt.core.dom.ASTNode;
050import org.jboss.forge.roaster._shade.org.eclipse.jdt.core.dom.Javadoc;
051import org.jboss.forge.roaster.model.JavaDoc;
052import org.jboss.forge.roaster.model.JavaDocCapable;
053import org.jboss.forge.roaster.model.JavaDocTag;
054import org.jboss.forge.roaster.model.JavaType;
055import org.jboss.forge.roaster.model.impl.JavaDocImpl;
056import org.jboss.forge.roaster.model.source.FieldSource;
057import org.jboss.forge.roaster.model.source.JavaClassSource;
058import org.jboss.forge.roaster.model.source.JavaDocSource;
059import org.objectweb.asm.AnnotationVisitor;
060import org.objectweb.asm.ClassReader;
061import org.objectweb.asm.ClassVisitor;
062import org.objectweb.asm.FieldVisitor;
063import org.objectweb.asm.Opcodes;
064import picocli.CommandLine;
065
066@CommandLine.Command(name = "docgen", description = "Maven Documentation Generator")
067public class CollectConfiguration implements Callable<Integer> {
068    public static void main(String[] args) {
069        new CommandLine(new CollectConfiguration()).execute(args);
070    }
071
072    protected static final String KEY = "key";
073
074    enum Mode {
075        maven,
076        resolver
077    }
078
079    @CommandLine.Option(
080            names = {"-m", "--mode"},
081            arity = "1",
082            paramLabel = "mode",
083            description = "The mode of generator (what is being scanned?), supported modes are 'maven', 'resolver'")
084    protected Mode mode;
085
086    @CommandLine.Option(
087            names = {"-t", "--templates"},
088            arity = "1",
089            split = ",",
090            paramLabel = "template",
091            description = "The template names to write content out without '.vm' extension")
092    protected List<String> templates;
093
094    @CommandLine.Parameters(index = "0", description = "The root directory to process sources from")
095    protected Path rootDirectory;
096
097    @CommandLine.Parameters(index = "1", description = "The directory to generate output(s) to")
098    protected Path outputDirectory;
099
100    @Override
101    public Integer call() {
102        try {
103            rootDirectory = rootDirectory.toAbsolutePath().normalize();
104            outputDirectory = outputDirectory.toAbsolutePath().normalize();
105
106            ArrayList<Map<String, String>> discoveredKeys = new ArrayList<>();
107            try (Stream<Path> stream = Files.walk(rootDirectory)) {
108                if (mode == Mode.maven) {
109                    System.out.println("Processing Maven sources from " + rootDirectory);
110                    stream.map(Path::toAbsolutePath)
111                            .filter(p -> p.getFileName().toString().endsWith(".class"))
112                            .filter(p -> p.toString().contains("/target/classes/"))
113                            .forEach(p -> {
114                                processMavenClass(p, discoveredKeys);
115                            });
116                } else if (mode == Mode.resolver) {
117                    System.out.println("Processing Resolver sources from " + rootDirectory);
118                    stream.map(Path::toAbsolutePath)
119                            .filter(p -> p.getFileName().toString().endsWith(".java"))
120                            .filter(p -> p.toString().contains("/src/main/java/"))
121                            .filter(p -> !p.toString().endsWith("/module-info.java"))
122                            .forEach(p -> processResolverClass(p, discoveredKeys));
123                } else {
124                    throw new IllegalStateException("Unsupported mode " + mode);
125                }
126            }
127
128            Collections.sort(discoveredKeys, Comparator.comparing(e -> e.get(KEY)));
129
130            Properties properties = new Properties();
131            properties.setProperty("resource.loaders", "classpath");
132            properties.setProperty("resource.loader.classpath.class", ClasspathResourceLoader.class.getName());
133            VelocityEngine velocityEngine = new VelocityEngine();
134            velocityEngine.init(properties);
135
136            VelocityContext context = new VelocityContext();
137            context.put("keys", discoveredKeys);
138
139            for (String template : templates) {
140                Path output = outputDirectory.resolve(template);
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 =
198                                                        switch ((values.get("source") != null
199                                                                        ? (String) values.get("source")
200                                                                        : "USER_PROPERTIES") // TODO: enum
201                                                                .toLowerCase()) {
202                                                            case "model" -> "Model properties";
203                                                            case "user_properties" -> "User properties";
204                                                            default -> throw new IllegalStateException();
205                                                        };
206                                                String type =
207                                                        switch ((values.get("type") != null
208                                                                ? (String) values.get("type")
209                                                                : "java.lang.String")) {
210                                                            case "java.lang.String" -> "String";
211                                                            case "java.lang.Integer" -> "Integer";
212                                                            case "java.lang.Boolean" -> "Boolean";
213                                                            default -> throw new IllegalStateException();
214                                                        };
215                                                discoveredKeys.add(Map.of(
216                                                        KEY,
217                                                        fieldValue.toString(),
218                                                        "defaultValue",
219                                                        values.get("defaultValue") != null
220                                                                ? values.get("defaultValue")
221                                                                        .toString()
222                                                                : "",
223                                                        "fqName",
224                                                        nvl(fqName, ""),
225                                                        "description",
226                                                        desc,
227                                                        "since",
228                                                        nvl(since, ""),
229                                                        "configurationSource",
230                                                        source,
231                                                        "configurationType",
232                                                        type));
233                                            }
234                                        };
235                                    }
236                                    return null;
237                                }
238                            };
239                        }
240                    },
241                    0);
242        } catch (IOException e) {
243            throw new RuntimeException(e);
244        }
245    }
246
247    protected void processResolverClass(Path path, List<Map<String, String>> discoveredKeys) {
248        JavaType<?> type = parse(path);
249        if (type instanceof JavaClassSource javaClassSource) {
250            javaClassSource.getFields().stream()
251                    .filter(this::hasConfigurationSource)
252                    .forEach(f -> {
253                        Map<String, String> constants = extractConstants(Paths.get(path.toString()
254                                .replace("/src/main/java/", "/target/classes/")
255                                .replace(".java", ".class")));
256
257                        String name = f.getName();
258                        String key = constants.get(name);
259                        String fqName = f.getOrigin().getCanonicalName() + "." + name;
260                        String configurationType = getConfigurationType(f);
261                        String defValue = getTag(f, "@configurationDefaultValue");
262                        if (defValue != null && defValue.startsWith("{@link #") && defValue.endsWith("}")) {
263                            // constant "lookup"
264                            String lookupValue = constants.get(defValue.substring(8, defValue.length() - 1));
265                            if (lookupValue == null) {
266                                // currently we hard fail if javadoc cannot be looked up
267                                // workaround: at cost of redundancy, but declare constants in situ for now
268                                // (in same class)
269                                throw new IllegalArgumentException(
270                                        "Could not look up " + defValue + " for configuration " + fqName);
271                            }
272                            defValue = lookupValue;
273                            if ("java.lang.Long".equals(configurationType)
274                                    && (defValue.endsWith("l") || defValue.endsWith("L"))) {
275                                defValue = defValue.substring(0, defValue.length() - 1);
276                            }
277                        }
278                        discoveredKeys.add(Map.of(
279                                KEY,
280                                key,
281                                "defaultValue",
282                                nvl(defValue, ""),
283                                "fqName",
284                                fqName,
285                                "description",
286                                cleanseJavadoc(f),
287                                "since",
288                                nvl(getSince(f), ""),
289                                "configurationSource",
290                                getConfigurationSource(f),
291                                "configurationType",
292                                configurationType,
293                                "supportRepoIdSuffix",
294                                toYesNo(getTag(f, "@configurationRepoIdSuffix"))));
295                    });
296        }
297    }
298
299    protected JavaDocSource<Object> cloneJavadoc(JavaDocSource<?> javaDoc) {
300        Javadoc jd = (Javadoc) javaDoc.getInternal();
301        return new JavaDocImpl<>(javaDoc.getOrigin(), (Javadoc)
302                ASTNode.copySubtree(AST.newAST(jd.getAST().apiLevel(), false), jd));
303    }
304
305    protected String cleanseJavadoc(FieldSource<JavaClassSource> javaClassSource) {
306        JavaDoc<FieldSource<JavaClassSource>> javaDoc = javaClassSource.getJavaDoc();
307        String[] text = javaDoc.getFullText().split("\n");
308        StringBuilder result = new StringBuilder();
309        for (String line : text) {
310            if (!line.startsWith("@") && !line.trim().isEmpty()) {
311                result.append(line);
312            }
313        }
314        return cleanseTags(result.toString());
315    }
316
317    protected String cleanseTags(String text) {
318        // {@code XXX} -> <pre>XXX</pre>
319        // {@link XXX} -> ??? pre for now
320        Pattern pattern = Pattern.compile("(\\{@\\w\\w\\w\\w (.+?)})");
321        Matcher matcher = pattern.matcher(text);
322        if (!matcher.find()) {
323            return text;
324        }
325        int prevEnd = 0;
326        StringBuilder result = new StringBuilder();
327        do {
328            result.append(text, prevEnd, matcher.start(1));
329            result.append("<code>");
330            result.append(matcher.group(2));
331            result.append("</code>");
332            prevEnd = matcher.end(1);
333        } while (matcher.find());
334        result.append(text, prevEnd, text.length());
335        return result.toString();
336    }
337
338    protected JavaType<?> parse(Path path) {
339        try {
340            return Roaster.parse(path.toFile());
341        } catch (IOException e) {
342            throw new UncheckedIOException(e);
343        }
344    }
345
346    protected String toYesNo(String value) {
347        return "yes".equalsIgnoreCase(value) || "true".equalsIgnoreCase(value) ? "Yes" : "No";
348    }
349
350    protected String nvl(String string, String def) {
351        return string == null ? def : string;
352    }
353
354    protected boolean hasConfigurationSource(JavaDocCapable<?> javaDocCapable) {
355        return getTag(javaDocCapable, "@configurationSource") != null;
356    }
357
358    protected String getConfigurationType(JavaDocCapable<?> javaDocCapable) {
359        String type = getTag(javaDocCapable, "@configurationType");
360        if (type != null) {
361            String linkPrefix = "{@link ";
362            String linkSuffix = "}";
363            if (type.startsWith(linkPrefix) && type.endsWith(linkSuffix)) {
364                type = type.substring(linkPrefix.length(), type.length() - linkSuffix.length());
365            }
366            String javaLangPackage = "java.lang.";
367            if (type.startsWith(javaLangPackage)) {
368                type = type.substring(javaLangPackage.length());
369            }
370        }
371        return nvl(type, "n/a");
372    }
373
374    protected String getConfigurationSource(JavaDocCapable<?> javaDocCapable) {
375        String source = getTag(javaDocCapable, "@configurationSource");
376        if ("{@link RepositorySystemSession#getConfigProperties()}".equals(source)) {
377            return "Session Configuration";
378        } else if ("{@link System#getProperty(String,String)}".equals(source)) {
379            return "Java System Properties";
380        } else {
381            return source;
382        }
383    }
384
385    protected String getSince(JavaDocCapable<?> javaDocCapable) {
386        List<JavaDocTag> tags;
387        if (javaDocCapable != null) {
388            if (javaDocCapable instanceof FieldSource<?> fieldSource) {
389                tags = fieldSource.getJavaDoc().getTags("@since");
390                if (tags.isEmpty()) {
391                    return getSince(fieldSource.getOrigin());
392                } else {
393                    return tags.get(0).getValue();
394                }
395            } else if (javaDocCapable instanceof JavaClassSource classSource) {
396                tags = classSource.getJavaDoc().getTags("@since");
397                if (!tags.isEmpty()) {
398                    return tags.get(0).getValue();
399                }
400            }
401        }
402        return null;
403    }
404
405    protected String getTag(JavaDocCapable<?> javaDocCapable, String tagName) {
406        List<JavaDocTag> tags;
407        if (javaDocCapable != null) {
408            if (javaDocCapable instanceof FieldSource<?> fieldSource) {
409                tags = fieldSource.getJavaDoc().getTags(tagName);
410                if (tags.isEmpty()) {
411                    return getTag(fieldSource.getOrigin(), tagName);
412                } else {
413                    return tags.get(0).getValue();
414                }
415            }
416        }
417        return null;
418    }
419
420    protected static final Pattern CONSTANT_PATTERN = Pattern.compile(".*static final.* ([A-Z_]+) = (.*);");
421
422    protected static final ToolProvider JAVAP = ToolProvider.findFirst("javap").orElseThrow();
423
424    /**
425     * Builds "constant table" for one single class.
426     * <p>
427     * Limitations:
428     * - works only for single class (no inherited constants)
429     * - does not work for fields that are Enum.name()
430     * - more to come
431     */
432    protected static Map<String, String> extractConstants(Path file) {
433        StringWriter out = new StringWriter();
434        JAVAP.run(new PrintWriter(out), new PrintWriter(System.err), "-constants", file.toString());
435        Map<String, String> result = new HashMap<>();
436        out.getBuffer().toString().lines().forEach(l -> {
437            Matcher matcher = CONSTANT_PATTERN.matcher(l);
438            if (matcher.matches()) {
439                result.put(matcher.group(1), matcher.group(2));
440            }
441        });
442        return result;
443    }
444}