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 private Snippet extractSnippet(Diagnostic<? extends JavaFileObject> diagnostic) { 112 var source = diagnostic.getSource(); 113 114 var noSnippet = source == null 115 || diagnostic.getStartPosition() == NOPOS 116 || diagnostic.getEndPosition() == NOPOS; 117 118 if (noSnippet) { 119 // No info available about position, or no source available, so don't bother extracting 120 // anything. 121 return null; 122 } 123 124 var content = tryGetContents(diagnostic.getSource()); 125 126 if (content == null) { 127 // Unable to read the file for whatever reason, so don't bother extracting anything. 128 return null; 129 } 130 131 var startLine = Math.max(1, (int) diagnostic.getLineNumber() - ADDITIONAL_CONTEXT_LINES); 132 var lineStartOffset = StringUtils.indexOfLine(content, startLine); 133 var endOffset = (int) diagnostic.getEndPosition(); 134 135 // Advance to include the additional lines of context, and don't treat the current line as 136 // being one of those lines if we span over one and a half lines. 137 var endOfSnippet = endOffset >= content.length() || content.charAt(endOffset) == '\n' 138 ? endOffset 139 : StringUtils.indexOfEndOfLine(content, endOffset); 140 141 for (var i = 0; i < ADDITIONAL_CONTEXT_LINES; ++i) { 142 endOfSnippet = StringUtils.indexOfEndOfLine(content, endOfSnippet + 1); 143 } 144 145 return new Snippet( 146 content.substring(lineStartOffset, endOfSnippet), 147 Math.max(1, diagnostic.getLineNumber() - ADDITIONAL_CONTEXT_LINES), 148 diagnostic.getStartPosition() - lineStartOffset, 149 endOffset - lineStartOffset 150 ); 151 } 152 153 private static String tryGetContents(FileObject fileObject) { 154 // We may not always be able to read the contents of a file object correctly. This may be down 155 // to IO exceptions occurring on the disk, or it may be due to the components under-test 156 // using custom FileObject implementations that do not play nicely with being accessed like 157 // this (Manifold being an example here). To work around this, we operate on a best-effort 158 // basis only. Some calls just do not work properly under certain compilers either 159 // (getCharContent() can randomly fail under ECJ for example). 160 161 var implName = fileObject.getClass().getName(); 162 var uri = fileObject.toUri(); 163 164 // Try to get the content via the getCharContent method. 165 try { 166 var content = fileObject.getCharContent(true); 167 if (content != null) { 168 return content.toString(); 169 } 170 } catch (Exception ex) { 171 LOGGER.debug("Failed to read char content for file object {} ({})", uri, implName, ex); 172 } 173 174 // If that fails, attempt to use an input stream to deal with this. 175 try (var input = fileObject.openInputStream()) { 176 var contentBytes = new ByteArrayOutputStream(); 177 input.transferTo(contentBytes); 178 return contentBytes.toString(StandardCharsets.UTF_8); 179 } catch (Exception ex) { 180 LOGGER.debug("Failed to read input stream for file object {} ({})", uri, implName, ex); 181 } 182 183 // If we still cannot get anywhere, we should just give up. 184 LOGGER.warn( 185 "Failed to read content for file object {} ({}), cannot produce a snippet preview. " 186 + "Enable debug logs to see the cause of this issue.", 187 uri, 188 implName 189 ); 190 191 return null; 192 } 193 194 private static final class Snippet { 195 196 private final String text; 197 private final long startLine; 198 private final long startOffset; 199 private final long endOffset; 200 private final int lineNumberWidth; 201 202 private Snippet(String text, long startLine, long startOffset, long endOffset) { 203 this.text = text; 204 this.startLine = startLine; 205 this.startOffset = startOffset; 206 this.endOffset = endOffset; 207 208 // Width of the line number part of the output. 209 lineNumberWidth = (int) Math.ceil(Math.log10(startLine)) + 1; 210 } 211 212 public void prettyPrintTo(StringBuilder builder) { 213 var lineIndex = 0; 214 var isNewLine = true; 215 var startOfLine = 0; 216 217 for (var i = 0; i < text.length(); ++i) { 218 if (isNewLine) { 219 appendLineNumberPart(builder, lineIndex); 220 isNewLine = false; 221 } 222 223 var nextChar = text.charAt(i); 224 builder.append(nextChar); 225 226 if (nextChar == '\n' || i == text.length() - 1) { 227 if (nextChar != '\n') { 228 // Ensure newline if we are adding the end-of-content line 229 builder.append('\n'); 230 } 231 232 appendPossibleUnderline(builder, startOfLine, i); 233 ++lineIndex; 234 startOfLine = i + 1; 235 isNewLine = true; 236 } 237 } 238 } 239 240 private void appendLineNumberPart(StringBuilder builder, int lineIndex) { 241 var rawLineString = Long.toString(lineIndex + startLine); 242 var paddedLineString = StringUtils.leftPad(rawLineString, lineNumberWidth, ' '); 243 244 builder 245 .append(PADDING) 246 .append(paddedLineString) 247 .append(" | "); 248 } 249 250 private void appendPossibleUnderline(StringBuilder builder, int startOfLine, int endOfLine) { 251 if (startOfLine > endOffset || endOfLine < startOffset) { 252 return; 253 } 254 255 builder 256 .append(PADDING) 257 .append(" ".repeat(lineNumberWidth)) 258 .append(" + "); 259 260 var endIndex = Math.min(endOfLine, endOffset); 261 for (int i = startOfLine; i < endIndex; ++i) { 262 if (i < startOffset) { 263 builder.append(' '); 264 } else if (i == startOffset || i == endOffset - 1) { 265 builder.append('^'); 266 } else { 267 builder.append('~'); 268 } 269 } 270 271 builder.append('\n'); 272 } 273 } 274}