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