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}