001/*
002 * Copyright (C) 2022 - 2024, 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(' ')
080          .append(code);
081    }
082
083    if (diagnostic.getSource() != null) {
084      builder
085          .append(' ')
086          .append(diagnostic.getSource().getName())
087          .append(" (at line ")
088          .append(diagnostic.getLineNumber())
089          .append(", col ")
090          .append(diagnostic.getColumnNumber())
091          .append(")");
092    }
093
094    builder.append("\n\n");
095
096    var snippet = extractSnippet(diagnostic);
097
098    if (snippet != null) {
099      snippet.prettyPrintTo(builder);
100      builder.append('\n');
101    }
102
103    var message = IoExceptionUtils
104        .uncheckedIo(() -> diagnostic.getMessage(Locale.ROOT));
105
106    return builder
107        .append(PADDING)
108        .append(message)
109        .toString();
110  }
111
112  @Nullable
113  private Snippet extractSnippet(Diagnostic<? extends JavaFileObject> diagnostic) {
114    var source = diagnostic.getSource();
115
116    var noSnippet = source == null
117        || diagnostic.getStartPosition() == NOPOS
118        || diagnostic.getEndPosition() == NOPOS;
119
120    if (noSnippet) {
121      // No info available about position, or no source available, so don't bother extracting
122      // anything.
123      return null;
124    }
125
126    var content = tryGetContents(diagnostic.getSource());
127
128    if (content == null) {
129      // Unable to read the file for whatever reason, so don't bother extracting anything.
130      return null;
131    }
132
133    var startLine = Math.max(1, (int) diagnostic.getLineNumber() - ADDITIONAL_CONTEXT_LINES);
134    var lineStartOffset = StringUtils.indexOfLine(content, startLine);
135    var endOffset = (int) diagnostic.getEndPosition();
136
137    // Advance to include the additional lines of context, and don't treat the current line as
138    // being one of those lines if we span over one and a half lines.
139    var endOfSnippet = endOffset >= content.length() || content.charAt(endOffset) == '\n'
140        ? endOffset
141        : StringUtils.indexOfEndOfLine(content, endOffset);
142
143    for (var index = 0; index < ADDITIONAL_CONTEXT_LINES; ++index) {
144      // For each additional context line we should add, get the end of the line.
145      // We do this incrementally as we cannot compute in advance where the line
146      // is going to end.
147      endOfSnippet = StringUtils.indexOfEndOfLine(content, endOfSnippet + 1);
148    }
149
150    return new Snippet(
151        content.substring(lineStartOffset, endOfSnippet),
152        Math.max(1, diagnostic.getLineNumber() - ADDITIONAL_CONTEXT_LINES),
153        diagnostic.getStartPosition() - lineStartOffset,
154        endOffset - lineStartOffset
155    );
156  }
157
158  @Nullable
159  private static String tryGetContents(FileObject fileObject) {
160    // We may not always be able to read the contents of a file object correctly. This may be down
161    // to IO exceptions occurring on the disk, or it may be due to the components under-test
162    // using custom FileObject implementations that do not play nicely with being accessed like
163    // this (Manifold being an example here). To work around this, we operate on a best-effort
164    // basis only. Some calls just do not work properly under certain compilers either
165    // (getCharContent() can randomly fail under ECJ for example).
166
167    var implName = fileObject.getClass().getName();
168    var uri = fileObject.toUri();
169
170    // Try to get the content via the getCharContent method.
171    try {
172      var content = fileObject.getCharContent(true);
173      if (content != null) {
174        return content.toString();
175      }
176    } catch (Exception ex) {
177      LOGGER.debug("Failed to read char content for file object {} ({})", uri, implName, ex);
178    }
179
180    // If that fails, attempt to use an input stream to deal with this.
181    try (var input = fileObject.openInputStream()) {
182      var contentBytes = new ByteArrayOutputStream();
183      input.transferTo(contentBytes);
184      return contentBytes.toString(StandardCharsets.UTF_8);
185    } catch (Exception ex) {
186      LOGGER.debug("Failed to read input stream for file object {} ({})", uri, implName, ex);
187    }
188
189    // If we still cannot get anywhere, we should just give up.
190    LOGGER.warn(
191        "Failed to read content for file object {} ({}), cannot produce a snippet preview. "
192            + "Enable debug logs to see the cause of this issue.",
193        uri,
194        implName
195    );
196
197    return null;
198  }
199
200  private static final class Snippet {
201
202    private final String text;
203    private final long startLine;
204    private final long startOffset;
205    private final long endOffset;
206    private final int lineNumberWidth;
207
208    private Snippet(String text, long startLine, long startOffset, long endOffset) {
209      this.text = text;
210      this.startLine = startLine;
211      this.startOffset = startOffset;
212      this.endOffset = endOffset;
213
214      // Width of the line number part of the output.
215      // Use log10 to determine the number of decimal digits
216      // to use. This allows us to produce a consistent width
217      // margin that will fit every line number. We only show a
218      // few lines in the snippet usually, so adding 1 more
219      // digit to this provides us safety if the snippet were
220      // to start on line 99 and move into line 100.
221      lineNumberWidth = getDigitCount(startLine) + 1;
222    }
223
224    public void prettyPrintTo(StringBuilder builder) {
225      var lineIndex = 0;
226      var isNewLine = true;
227      var startOfLine = 0;
228
229      for (var index = 0; index < text.length(); ++index) {
230        if (isNewLine) {
231          appendLineNumberPart(builder, lineIndex);
232          isNewLine = false;
233        }
234
235        var nextChar = text.charAt(index);
236        builder.append(nextChar);
237
238        if (nextChar == '\n' || index == text.length() - 1) {
239          if (nextChar != '\n') {
240            // Ensure newline if we are adding the end-of-content line
241            builder.append('\n');
242          }
243
244          appendPossibleUnderline(builder, startOfLine, index);
245          ++lineIndex;
246          startOfLine = index + 1;
247          isNewLine = true;
248        }
249      }
250    }
251
252    private int getDigitCount(long number) {
253      return (int) Math.ceil(Math.log10(number));
254    }
255
256    private void appendLineNumberPart(StringBuilder builder, int lineIndex) {
257      var rawLineString = Long.toString(lineIndex + startLine);
258      var paddedLineString = StringUtils.leftPad(rawLineString, lineNumberWidth, ' ');
259
260      builder
261          .append(PADDING)
262          .append(paddedLineString)
263          .append(" | ");
264    }
265
266    private void appendPossibleUnderline(StringBuilder builder, int startOfLine, int endOfLine) {
267      // The idea here is to consider where abouts we are in the snippet we are formatting.
268      // If we find that part of the marked position range in the diagnostic lies within the
269      // current line, we will attempt to create an extra "underlining" line after the current
270      // line.
271      //
272      // This involves marking the beginning and end of the diagnostic position range by using
273      // the ^ caret character, and filling the middle in with the ~ tilde character.
274      //
275      // Each underline line will start with the line number gutter left empty, and the vertical
276      // separator will use the + plus character rather than a vertical pipe | character.
277      //
278      // Output rendering is complicated a little further because the start position of the content
279      // to underline may not be on the same line as the end position, so we would need to then
280      // wrap around to multiple lines to achieve this.
281      //
282      // Expected output looks like this...
283      //
284      //  010 | public class App {
285      //  011 |   public static void main(String[[] args) {
286      //      +                           ^~~~~~~~^
287      //  012 |     System.out.println("Hello, World!");
288      //  013 |   }
289      //
290      // ...or when across multiple lines...
291      //
292      //    009 |
293      //    010 |   public static void main(String[] args) {
294      //        +                 ^~~~~~~~~~~~~~~~~~~~~~~~~~
295      //    011 |     System.out.println("Hello, World!");
296      //        + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
297      //    012 |     return false;
298      //        + ~~~~~~~~~~~~~~~~^
299      //    013 |   }
300      //    014 | }
301
302      // If we are after the snippet or before the snippet and neither the start nor the
303      // end is on the current line we are considering, then we do not do anything.
304      if (startOfLine > endOffset || endOfLine < startOffset) {
305        return;
306      }
307
308      builder
309          .append(PADDING)
310          .append(" ".repeat(lineNumberWidth))
311          .append(" + ");
312
313      // Go to the end of the line or the end of the highlighted section, whichever
314      // comes first.
315      var endIndex = Math.min(endOfLine, endOffset);
316
317      // If we are after the end of the snippet, we will not do anything.
318      for (var index = startOfLine; index < endIndex; ++index) {
319        // If we are before the start of the highlighted section, but still on the line it starts
320        // at, then add padding space characters.
321        if (index < startOffset) {
322          builder.append(' ');
323          continue;
324        }
325
326        // If we are on the start or end of the highlighted section, add a caret to mark it.
327        if (index == startOffset || index == endOffset - 1) {
328          builder.append('^');
329          continue;
330        }
331
332        // Otherwise, we are in the middle of the highlighted section, so add the tilde.
333        builder.append('~');
334      }
335
336      // Since getting this far implies we are adding an underline, we need an additional
337      // line feed to finish.
338      builder.append('\n');
339    }
340  }
341}