TextReporter.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.maven.cling.invoker.mvnval;

import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.apache.maven.api.services.BuilderProblem;
import org.apache.maven.api.services.MessageBuilder;
import org.apache.maven.api.services.ModelProblem;
import org.apache.maven.jline.MessageUtils;

/**
 * Renders validation results as plain text, one problem per line, with optional
 * source-context lines and ANSI color when the terminal supports it.
 */
class TextReporter {

    private static final String INDENT = "  ";
    private static final int MAX_LINE_WIDTH = 120;

    static void report(List<Report> reports, Path cwd, Consumer<String> out, int context, boolean color) {
        Path base = realPath(cwd);
        for (Report report : reports) {
            String pom = display(report.pom(), base);
            if (report.failure() != null) {
                out.accept(newBuilder(color).strong(pom + ":").build() + " " + report.failure());
            } else if (report.clean()) {
                out.accept(newBuilder(color).success(pom + ": no problems").build());
            } else {
                out.accept(newBuilder(color).strong(pom + ":").build());
                for (ModelProblem problem : report.problems()) {
                    out.accept(problemLine(problem, report, color));
                    if (context > 0 && problem.getLineNumber() > 0) {
                        contextLines(problem, report, context, color, out);
                    }
                }
            }
        }
    }

    /** Returns a color-aware or plain {@link MessageBuilder}. */
    private static MessageBuilder newBuilder(boolean color) {
        return color ? MessageUtils.builder() : new PlainMessageBuilder();
    }

    /**
     * The path as the caller would have typed it: relative to the working directory when the file
     * is under it, absolute otherwise, so a path outside the tree is never ambiguous. The JSON
     * document keeps the absolute path, since whoever reads it may not share this directory.
     */
    private static String display(Path pom, Path base) {
        return pom.startsWith(base) ? base.relativize(pom).toString() : pom.toString();
    }

    /**
     * Both paths have to be canonical before one can be said to sit under the other. On macOS the
     * working directory is routinely reached through a symbolic link, and comparing the link
     * against the resolved file gives a relative path that climbs out and back in again.
     */
    private static Path realPath(Path path) {
        try {
            return path.toRealPath();
        } catch (IOException e) {
            return path.toAbsolutePath().normalize();
        }
    }

    private static String problemLine(ModelProblem problem, Report report, boolean color) {
        // A parser failure arrives as a message of several lines; the continuations are indented
        // one step deeper than the entry so it still reads as one.
        String message = String.valueOf(problem.getMessage())
                .lines()
                .collect(Collectors.joining(System.lineSeparator() + INDENT + INDENT));

        MessageBuilder b = newBuilder(color).a(INDENT);
        BuilderProblem.Severity severity = problem.getSeverity();
        if (severity == BuilderProblem.Severity.FATAL || severity == BuilderProblem.Severity.ERROR) {
            b.failure(severity.toString());
        } else {
            b.warning(severity.toString());
        }
        b.a(" ").a(message);

        String where = location(problem, report);
        if (!where.isEmpty()) {
            b.a(" @ ").a(where);
        }
        return b.build();
    }

    /**
     * Where the problem sits, as {@code file, line N, column M}, leaving out whatever is unknown.
     * The file is named only when it is not the one being reported on.
     * <p>
     * {@link ModelProblem#getLocation()} is no use here: {@code DefaultModelProblem}, which is
     * what the model builder produces, hardcodes it to an empty string.
     */
    private static String location(ModelProblem problem, Report report) {
        return Stream.of(
                        report.sourceOf(problem),
                        problem.getLineNumber() > 0 ? "line " + problem.getLineNumber() : null,
                        problem.getColumnNumber() > 0 ? "column " + problem.getColumnNumber() : null)
                .filter(Objects::nonNull)
                .collect(Collectors.joining(", "));
    }

    /**
     * Reads the source file for the problem and emits the surrounding {@code context} lines,
     * with a {@code >} marker on the problem line and line numbers as a gutter.
     * <p>
     * Lines longer than {@value #MAX_LINE_WIDTH} characters are truncated with an ellipsis so
     * the output does not wrap on a standard terminal.
     */
    private static void contextLines(
            ModelProblem problem, Report report, int context, boolean color, Consumer<String> out) {
        Path source = resolveSourcePath(problem, report);
        if (source == null || !Files.isRegularFile(source)) {
            return;
        }
        List<String> lines;
        try {
            lines = Files.readAllLines(source, StandardCharsets.UTF_8);
        } catch (IOException e) {
            return;
        }
        int total = lines.size();
        int problemLine = problem.getLineNumber(); // 1-based
        if (problemLine < 1 || problemLine > total) {
            return;
        }
        int first = Math.max(1, problemLine - context);
        int last = Math.min(total, problemLine + context);

        // Width of the line-number gutter: enough digits for the largest line number shown.
        int gutterWidth = Integer.toString(last).length();

        for (int i = first; i <= last; i++) {
            String text = lines.get(i - 1);
            if (text.length() > MAX_LINE_WIDTH) {
                text = text.substring(0, MAX_LINE_WIDTH - 1) + "\u2026";
            }
            boolean isProb = (i == problemLine);
            String marker = isProb ? ">" : " ";
            String num = String.format("%" + gutterWidth + "d", i);
            MessageBuilder b = newBuilder(color).a(INDENT).a(marker).a(" ");
            if (isProb) {
                b.strong(num + " | " + text);
            } else {
                b.a(num).a(" | ").a(text);
            }
            out.accept(b.build());
        }
    }

    /**
     * Resolves the path of the file containing the problem. Falls back to the report's own POM
     * when the problem has no source or the source cannot be converted to a path.
     */
    private static Path resolveSourcePath(ModelProblem problem, Report report) {
        String source = problem.getSource();
        if (source == null || source.isEmpty()) {
            return report.pom();
        }
        try {
            Path path = source.startsWith("file:") ? Path.of(URI.create(source)) : Path.of(source);
            path = path.normalize();
            return path.isAbsolute() ? path : report.pom().resolveSibling(path);
        } catch (IllegalArgumentException e) {
            return report.pom();
        }
    }

    /**
     * A plain {@link MessageBuilder} that produces text with no ANSI escapes.
     * Used when color is disabled.
     */
    private static final class PlainMessageBuilder implements MessageBuilder {

        private final StringBuilder buf = new StringBuilder();

        @Override
        public MessageBuilder style(String style) {
            return this;
        }

        @Override
        public MessageBuilder resetStyle() {
            return this;
        }

        @Override
        public MessageBuilder append(CharSequence cs) {
            buf.append(cs);
            return this;
        }

        @Override
        public MessageBuilder append(CharSequence cs, int start, int end) {
            buf.append(cs, start, end);
            return this;
        }

        @Override
        public MessageBuilder append(char c) {
            buf.append(c);
            return this;
        }

        @Override
        public MessageBuilder setLength(int length) {
            buf.setLength(length);
            return this;
        }

        @Override
        public String build() {
            return buf.toString();
        }
    }
}