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}