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  @SuppressWarnings("ConstantConditions")
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  private static String tryGetContents(FileObject fileObject) {
155    // We may not always be able to read the contents of a file object correctly. This may be down
156    // to IO exceptions occurring on the disk, or it may be due to the components under-test
157    // using custom FileObject implementations that do not play nicely with being accessed like
158    // this (Manifold being an example here). To work around this, we operate on a best-effort
159    // basis only. Some calls just do not work properly under certain compilers either
160    // (getCharContent() can randomly fail under ECJ for example).
161
162    var implName = fileObject.getClass().getName();
163    var uri = fileObject.toUri();
164
165    // Try to get the content via the getCharContent method.
166    try {
167      var content = fileObject.getCharContent(true);
168      if (content != null) {
169        return content.toString();
170      }
171    } catch (Exception ex) {
172      LOGGER.debug("Failed to read char content for file object {} ({})", uri, implName, ex);
173    }
174
175    // If that fails, attempt to use an input stream to deal with this.
176    try (var input = fileObject.openInputStream()) {
177      var contentBytes = new ByteArrayOutputStream();
178      input.transferTo(contentBytes);
179      return contentBytes.toString(StandardCharsets.UTF_8);
180    } catch (Exception ex) {
181      LOGGER.debug("Failed to read input stream for file object {} ({})", uri, implName, ex);
182    }
183
184    // If we still cannot get anywhere, we should just give up.
185    LOGGER.warn(
186        "Failed to read content for file object {} ({}), cannot produce a snippet preview. "
187            + "Enable debug logs to see the cause of this issue.",
188        uri,
189        implName
190    );
191
192    return null;
193  }
194
195  private static final class Snippet {
196
197    private final String text;
198    private final long startLine;
199    private final long startOffset;
200    private final long endOffset;
201    private final int lineNumberWidth;
202
203    private Snippet(String text, long startLine, long startOffset, long endOffset) {
204      this.text = text;
205      this.startLine = startLine;
206      this.startOffset = startOffset;
207      this.endOffset = endOffset;
208
209      // Width of the line number part of the output.
210      lineNumberWidth = (int) Math.ceil(Math.log10(startLine)) + 1;
211    }
212
213    public void prettyPrintTo(StringBuilder builder) {
214      var lineIndex = 0;
215      var isNewLine = true;
216      var startOfLine = 0;
217
218      for (var i = 0; i < text.length(); ++i) {
219        if (isNewLine) {
220          appendLineNumberPart(builder, lineIndex);
221          isNewLine = false;
222        }
223
224        var nextChar = text.charAt(i);
225        builder.append(nextChar);
226
227        if (nextChar == '\n' || i == text.length() - 1) {
228          if (nextChar != '\n') {
229            // Ensure newline if we are adding the end-of-content line
230            builder.append('\n');
231          }
232
233          appendPossibleUnderline(builder, startOfLine, i);
234          ++lineIndex;
235          startOfLine = i + 1;
236          isNewLine = true;
237        }
238      }
239    }
240
241    private void appendLineNumberPart(StringBuilder builder, int lineIndex) {
242      var rawLineString = Long.toString(lineIndex + startLine);
243      var paddedLineString = StringUtils.leftPad(rawLineString, lineNumberWidth, ' ');
244
245      builder
246          .append(PADDING)
247          .append(paddedLineString)
248          .append(" | ");
249    }
250
251    private void appendPossibleUnderline(StringBuilder builder, int startOfLine, int endOfLine) {
252      if (startOfLine > endOffset || endOfLine < startOffset) {
253        return;
254      }
255
256      builder
257          .append(PADDING)
258          .append(" ".repeat(lineNumberWidth))
259          .append(" + ");
260
261      var endIndex = Math.min(endOfLine, endOffset);
262      for (int i = startOfLine; i < endIndex; ++i) {
263        if (i < startOffset) {
264          builder.append(' ');
265        } else if (i == startOffset || i == endOffset - 1) {
266          builder.append('^');
267        } else {
268          builder.append('~');
269        }
270      }
271
272      builder.append('\n');
273    }
274  }
275}