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                                                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}