001/*
002 * Copyright (C) 2022 - 2023, the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *    http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package io.github.ascopes.jct.repr;
017
018import static javax.tools.Diagnostic.NOPOS;
019
020import io.github.ascopes.jct.diagnostics.TraceDiagnostic;
021import io.github.ascopes.jct.utils.IoExceptionUtils;
022import io.github.ascopes.jct.utils.StringUtils;
023import java.io.ByteArrayOutputStream;
024import java.nio.charset.StandardCharsets;
025import java.util.Locale;
026import javax.tools.Diagnostic;
027import javax.tools.FileObject;
028import javax.tools.JavaFileObject;
029import org.apiguardian.api.API;
030import org.apiguardian.api.API.Status;
031import org.assertj.core.presentation.Representation;
032import org.jspecify.annotations.Nullable;
033import org.slf4j.Logger;
034import org.slf4j.LoggerFactory;
035
036/**
037 * Representation of a diagnostic.
038 *
039 * @author Ashley Scopes
040 * @since 0.0.1
041 */
042@API(since = "0.0.1", status = Status.STABLE)
043public final class TraceDiagnosticRepresentation implements Representation {
044
045  private static final TraceDiagnosticRepresentation INSTANCE
046      = new TraceDiagnosticRepresentation();
047
048  /**
049   * Get an instance of this diagnostic representation.
050   *
051   * @return the instance.
052   */
053  public static TraceDiagnosticRepresentation getInstance() {
054    return INSTANCE;
055  }
056
057  private static final Logger LOGGER = LoggerFactory.getLogger(TraceDiagnosticRepresentation.class);
058  private static final int ADDITIONAL_CONTEXT_LINES = 2;
059  private static final String PADDING = " ".repeat(4);
060
061  private TraceDiagnosticRepresentation() {
062    // Nothing to see here, move along now.
063  }
064
065  @Override
066  public String toStringOf(@Nullable Object object) {
067    if (object == null) {
068      return "null";
069    }
070
071    var diagnostic = (TraceDiagnostic<?>) object;
072
073    var builder = new StringBuilder("[")
074        .append(diagnostic.getKind())
075        .append("]");
076
077    var code = diagnostic.getCode();
078    if (code != null) {
079      builder.append(' ').append(code);
080    }
081
082    if (diagnostic.getSource() != null) {
083      builder
084          .append(' ')
085          .append(diagnostic.getSource().getName())
086          .append(" (at line ")
087          .append(diagnostic.getLineNumber())
088          .append(", col ")
089          .append(diagnostic.getColumnNumber())
090          .append(")");
091    }
092
093    builder.append("\n\n");
094
095    IoExceptionUtils.uncheckedIo(() -> {
096      var snippet = extractSnippet(diagnostic);
097
098      if (snippet != null) {
099        snippet.prettyPrintTo(builder);
100        builder.append('\n');
101      }
102
103      builder
104          .append(PADDING)
105          .append(diagnostic.getMessage(Locale.ROOT));
106    });
107
108    return builder.toString();
109  }
110
111  private Snippet extractSnippet(Diagnostic<? extends JavaFileObject> diagnostic) {
112    var source = diagnostic.getSource();
113
114    var noSnippet = source == null
115        || diagnostic.getStartPosition() == NOPOS
116        || diagnostic.getEndPosition() == NOPOS;
117
118    if (noSnippet) {
119      // No info available about position, or no source available, so don't bother extracting
120      // anything.
121      return null;
122    }
123
124    var content = tryGetContents(diagnostic.getSource());
125
126    if (content == null) {
127      // Unable to read the file for whatever reason, so don't bother extracting anything.
128      return null;
129    }
130
131    var startLine = Math.max(1, (int) diagnostic.getLineNumber() - ADDITIONAL_CONTEXT_LINES);
132    var lineStartOffset = StringUtils.indexOfLine(content, startLine);
133    var endOffset = (int) diagnostic.getEndPosition();
134
135    // Advance to include the additional lines of context, and don't treat the current line as
136    // being one of those lines if we span over one and a half lines.
137    var endOfSnippet = endOffset >= content.length() || content.charAt(endOffset) == '\n'
138        ? endOffset
139        : StringUtils.indexOfEndOfLine(content, endOffset);
140
141    for (var i = 0; i < ADDITIONAL_CONTEXT_LINES; ++i) {
142      endOfSnippet = StringUtils.indexOfEndOfLine(content, endOfSnippet + 1);
143    }
144
145    return new Snippet(
146        content.substring(lineStartOffset, endOfSnippet),
147        Math.max(1, diagnostic.getLineNumber() - ADDITIONAL_CONTEXT_LINES),
148        diagnostic.getStartPosition() - lineStartOffset,
149        endOffset - lineStartOffset
150    );
151  }
152
153  private static String tryGetContents(FileObject fileObject) {
154    // We may not always be able to read the contents of a file object correctly. This may be down
155    // to IO exceptions occurring on the disk, or it may be due to the components under-test
156    // using custom FileObject implementations that do not play nicely with being accessed like
157    // this (Manifold being an example here). To work around this, we operate on a best-effort
158    // basis only. Some calls just do not work properly under certain compilers either
159    // (getCharContent() can randomly fail under ECJ for example).
160
161    var implName = fileObject.getClass().getName();
162    var uri = fileObject.toUri();
163
164    // Try to get the content via the getCharContent method.
165    try {
166      var content = fileObject.getCharContent(true);
167      if (content != null) {
168        return content.toString();
169      }
170    } catch (Exception ex) {
171      LOGGER.debug("Failed to read char content for file object {} ({})", uri, implName, ex);
172    }
173
174    // If that fails, attempt to use an input stream to deal with this.
175    try (var input = fileObject.openInputStream()) {
176      var contentBytes = new ByteArrayOutputStream();
177      input.transferTo(contentBytes);
178      return contentBytes.toString(StandardCharsets.UTF_8);
179    } catch (Exception ex) {
180      LOGGER.debug("Failed to read input stream for file object {} ({})", uri, implName, ex);
181    }
182
183    // If we still cannot get anywhere, we should just give up.
184    LOGGER.warn(
185        "Failed to read content for file object {} ({}), cannot produce a snippet preview. "
186            + "Enable debug logs to see the cause of this issue.",
187        uri,
188        implName
189    );
190
191    return null;
192  }
193
194  private static final class Snippet {
195
196    private final String text;
197    private final long startLine;
198    private final long startOffset;
199    private final long endOffset;
200    private final int lineNumberWidth;
201
202    private Snippet(String text, long startLine, long startOffset, long endOffset) {
203      this.text = text;
204      this.startLine = startLine;
205      this.startOffset = startOffset;
206      this.endOffset = endOffset;
207
208      // Width of the line number part of the output.
209      lineNumberWidth = (int) Math.ceil(Math.log10(startLine)) + 1;
210    }
211
212    public void prettyPrintTo(StringBuilder builder) {
213      var lineIndex = 0;
214      var isNewLine = true;
215      var startOfLine = 0;
216
217      for (var i = 0; i < text.length(); ++i) {
218        if (isNewLine) {
219          appendLineNumberPart(builder, lineIndex);
220          isNewLine = false;
221        }
222
223        var nextChar = text.charAt(i);
224        builder.append(nextChar);
225
226        if (nextChar == '\n' || i == text.length() - 1) {
227          if (nextChar != '\n') {
228            // Ensure newline if we are adding the end-of-content line
229            builder.append('\n');
230          }
231
232          appendPossibleUnderline(builder, startOfLine, i);
233          ++lineIndex;
234          startOfLine = i + 1;
235          isNewLine = true;
236        }
237      }
238    }
239
240    private void appendLineNumberPart(StringBuilder builder, int lineIndex) {
241      var rawLineString = Long.toString(lineIndex + startLine);
242      var paddedLineString = StringUtils.leftPad(rawLineString, lineNumberWidth, ' ');
243
244      builder
245          .append(PADDING)
246          .append(paddedLineString)
247          .append(" | ");
248    }
249
250    private void appendPossibleUnderline(StringBuilder builder, int startOfLine, int endOfLine) {
251      if (startOfLine > endOffset || endOfLine < startOffset) {
252        return;
253      }
254
255      builder
256          .append(PADDING)
257          .append(" ".repeat(lineNumberWidth))
258          .append(" + ");
259
260      var endIndex = Math.min(endOfLine, endOffset);
261      for (int i = startOfLine; i < endIndex; ++i) {
262        if (i < startOffset) {
263          builder.append(' ');
264        } else if (i == startOffset || i == endOffset - 1) {
265          builder.append('^');
266        } else {
267          builder.append('~');
268        }
269      }
270
271      builder.append('\n');
272    }
273  }
274}