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}