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    var snippet = extractSnippet(diagnostic);
096
097    if (snippet != null) {
098      snippet.prettyPrintTo(builder);
099      builder.append('\n');
100    }
101
102    IoExceptionUtils.uncheckedIo(() -> { 
103      builder
104          .append(PADDING)
105          .append(diagnostic.getMessage(Locale.ROOT));
106    });
107
108    return builder.toString();
109  }
110
111  @Nullable
112  private Snippet extractSnippet(Diagnostic<? 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 i = 0; i < ADDITIONAL_CONTEXT_LINES; ++i) {
143      endOfSnippet = StringUtils.indexOfEndOfLine(content, endOfSnippet + 1);
144    }
145
146    return new Snippet(
147        content.substring(lineStartOffset, endOfSnippet),
148        Math.max(1, diagnostic.getLineNumber() - ADDITIONAL_CONTEXT_LINES),
149        diagnostic.getStartPosition() - lineStartOffset,
150        endOffset - lineStartOffset
151    );
152  }
153
154  @Nullable
155  private static String tryGetContents(FileObject fileObject) {
156    // We may not always be able to read the contents of a file object correctly. This may be down
157    // to IO exceptions occurring on the disk, or it may be due to the components under-test
158    // using custom FileObject implementations that do not play nicely with being accessed like
159    // this (Manifold being an example here). To work around this, we operate on a best-effort
160    // basis only. Some calls just do not work properly under certain compilers either
161    // (getCharContent() can randomly fail under ECJ for example).
162
163    var implName = fileObject.getClass().getName();
164    var uri = fileObject.toUri();
165
166    // Try to get the content via the getCharContent method.
167    try {
168      var content = fileObject.getCharContent(true);
169      if (content != null) {
170        return content.toString();
171      }
172    } catch (Exception ex) {
173      LOGGER.debug("Failed to read char content for file object {} ({})", uri, implName, ex);
174    }
175
176    // If that fails, attempt to use an input stream to deal with this.
177    try (var input = fileObject.openInputStream()) {
178      var contentBytes = new ByteArrayOutputStream();
179      input.transferTo(contentBytes);
180      return contentBytes.toString(StandardCharsets.UTF_8);
181    } catch (Exception ex) {
182      LOGGER.debug("Failed to read input stream for file object {} ({})", uri, implName, ex);
183    }
184
185    // If we still cannot get anywhere, we should just give up.
186    LOGGER.warn(
187        "Failed to read content for file object {} ({}), cannot produce a snippet preview. "
188            + "Enable debug logs to see the cause of this issue.",
189        uri,
190        implName
191    );
192
193    return null;
194  }
195
196  private static final class Snippet {
197
198    private final String text;
199    private final long startLine;
200    private final long startOffset;
201    private final long endOffset;
202    private final int lineNumberWidth;
203
204    private Snippet(String text, long startLine, long startOffset, long endOffset) {
205      this.text = text;
206      this.startLine = startLine;
207      this.startOffset = startOffset;
208      this.endOffset = endOffset;
209
210      // Width of the line number part of the output.
211      // Use log10 to determine the number of decimal digits
212      // to use. This allows us to produce a consistent width
213      // margin that will fit every line number. We only show a
214      // few lines in the snippet usually, so adding 1 more
215      // digit to this provides us safety if the snippet were
216      // to start on line 99 and move into line 100.
217      lineNumberWidth = (int) Math.ceil(Math.log10(startLine)) + 1;
218    }
219
220    public void prettyPrintTo(StringBuilder builder) {
221      var lineIndex = 0;
222      var isNewLine = true;
223      var startOfLine = 0;
224
225      for (var i = 0; i < text.length(); ++i) {
226        if (isNewLine) {
227          appendLineNumberPart(builder, lineIndex);
228          isNewLine = false;
229        }
230
231        var nextChar = text.charAt(i);
232        builder.append(nextChar);
233
234        if (nextChar == '\n' || i == text.length() - 1) {
235          if (nextChar != '\n') {
236            // Ensure newline if we are adding the end-of-content line
237            builder.append('\n');
238          }
239
240          appendPossibleUnderline(builder, startOfLine, i);
241          ++lineIndex;
242          startOfLine = i + 1;
243          isNewLine = true;
244        }
245      }
246    }
247
248    private void appendLineNumberPart(StringBuilder builder, int lineIndex) {
249      var rawLineString = Long.toString(lineIndex + startLine);
250      var paddedLineString = StringUtils.leftPad(rawLineString, lineNumberWidth, ' ');
251
252      builder
253          .append(PADDING)
254          .append(paddedLineString)
255          .append(" | ");
256    }
257
258    private void appendPossibleUnderline(StringBuilder builder, int startOfLine, int endOfLine) {
259      if (startOfLine > endOffset || endOfLine < startOffset) {
260        return;
261      }
262
263      builder
264          .append(PADDING)
265          .append(" ".repeat(lineNumberWidth))
266          .append(" + ");
267
268      var endIndex = Math.min(endOfLine, endOffset);
269      for (var i = startOfLine; i < endIndex; ++i) {
270        if (i < startOffset) {
271          builder.append(' ');
272        } else if (i == startOffset || i == endOffset - 1) {
273          builder.append('^');
274        } else {
275          builder.append('~');
276        }
277      }
278
279      builder.append('\n');
280    }
281  }
282}