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}