blob: 43ccf843753aee1f0faca67e19a44626aaded415 [file] [edit]
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:convert';
import 'package:analyzer/diagnostic/diagnostic.dart';
import 'package:analyzer/file_system/file_system.dart';
import 'package:analyzer/source/line_info.dart';
import 'package:analyzer_testing/utilities/extensions/diagnostic_code.dart';
/// Returns [content] with generated diagnostic expectation marker lines removed.
String removeDiagnosticExpectations(String content) {
var lines = _Line.parse(content);
var buffer = StringBuffer();
var isFirstLine = true;
for (var line in lines) {
if (_LineMarker.isMarker(line)) {
continue;
}
if (isFirstLine) {
isFirstLine = false;
} else {
buffer.writeln();
}
buffer.write(line.text);
}
return buffer.toString();
}
/// Returns [content] with one trailing line terminator removed, if present.
///
/// Diagnostic expectation tests often use multiline string literals where the
/// final line terminator exists only to keep the closing quote on its own line.
String removeTrailingLineTerminator(String content) {
if (content.endsWith('\r\n')) {
return content.substring(0, content.length - 2);
} else if (content.endsWith('\n') || content.endsWith('\r')) {
return content.substring(0, content.length - 1);
}
return content;
}
/// Returns [content] with canonical diagnostic expectation markers.
///
/// The [content] must not include diagnostic expectation marker lines. Use
/// [removeDiagnosticExpectations] before analysis, and pass the analyzed
/// content here.
String updateExpectedDiagnostics({
required String content,
required List<Diagnostic> actualDiagnostics,
}) {
return _ExpectedDiagnosticsUpdater(content).update(actualDiagnostics);
}
/// Returns each file's content with canonical diagnostic expectation markers.
///
/// This is the multi-file form of [updateExpectedDiagnostics]. It supports
/// diagnostics whose context messages are located in another file in
/// [contentByFile].
Map<File, String> updateExpectedDiagnosticsForFiles({
required Map<File, String> contentByFile,
required Map<File, List<Diagnostic>> actualDiagnosticsByFile,
}) {
return _ExpectedDiagnosticsForFilesUpdater(
contentByFile,
).update(actualDiagnosticsByFile);
}
final class _ExpectedDiagnosticsForFilesUpdater {
final Map<File, _ExpectedDiagnosticsUpdater> updatersByFile = {};
final Map<String, _ExpectedDiagnosticsUpdater> updatersByPath = {};
int nextMarkerIndex = 0;
int nextContextId = 1;
_ExpectedDiagnosticsForFilesUpdater(Map<File, String> contentByFile) {
for (var entry in contentByFile.entries) {
var updater = _ExpectedDiagnosticsUpdater(entry.value);
updatersByFile[entry.key] = updater;
updatersByPath[entry.key.path] = updater;
}
}
Map<File, String> update(
Map<File, List<Diagnostic>> actualDiagnosticsByFile,
) {
for (var entry in actualDiagnosticsByFile.entries) {
var updater = _updaterForPath(entry.key.path);
var sortedDiagnostics = entry.value.toList()
..sort((first, second) => first.offset.compareTo(second.offset));
for (var diagnostic in sortedDiagnostics) {
_generateDiagnosticMarkers(updater, diagnostic);
}
}
return {
for (var entry in updatersByFile.entries)
entry.key: entry.value._writeContent(),
};
}
void _generateDiagnosticMarkers(
_ExpectedDiagnosticsUpdater diagnosticUpdater,
Diagnostic diagnostic,
) {
var contextRefs = <int>[];
for (var contextMessage in diagnostic.contextMessages) {
var contextUpdater = _updaterForPath(contextMessage.filePath);
var id = nextContextId++;
contextRefs.add(id);
contextUpdater._addContextMessageMarker(
contextMessage,
id: id,
index: nextMarkerIndex++,
);
}
diagnosticUpdater._addDiagnosticMarker(
diagnostic,
index: nextMarkerIndex++,
contextRefs: contextRefs,
);
}
_ExpectedDiagnosticsUpdater _updaterForPath(String path) {
var updater = updatersByPath[path];
if (updater == null) {
throw StateError(
'Cannot generate diagnostic expectations for $path: '
'no content was provided.',
);
}
return updater;
}
}
final class _ExpectedDiagnosticsUpdater {
final List<_Line> lines;
final LineInfo lineInfo;
final Map<int, List<_GeneratedMarker>> markersByLine = {};
int nextMarkerIndex = 0;
int nextContextId = 1;
_ExpectedDiagnosticsUpdater(String content)
: lines = _Line.parse(content),
lineInfo = LineInfo.fromContent(content) {
for (var line in lines) {
if (_LineMarker.isMarker(line)) {
throw StateError(
'Expected content without diagnostic expectation markers, '
'found one on line ${line.number}.',
);
}
}
}
String update(List<Diagnostic> actualDiagnostics) {
_generateMarkers(actualDiagnostics);
return _writeContent();
}
void _addContextMessageMarker(
DiagnosticMessage contextMessage, {
required int id,
required int index,
}) {
var location = _markerLocation(offset: contextMessage.offset);
var line = lines[location.lineNumber - 1];
var presentation = _markerPresentation(
line,
column: location.column,
length: contextMessage.length,
);
_addMarker(
location.lineNumber,
_GeneratedMarker.context(
offset: contextMessage.offset,
index: index,
id: id,
column: location.column,
length: contextMessage.length,
caretLength: presentation.caretLength,
includeExplicitLocation: presentation.includeExplicitLocation,
message: _messageText(contextMessage),
),
);
}
void _addDiagnosticMarker(
Diagnostic diagnostic, {
required int index,
required List<int> contextRefs,
}) {
var location = _markerLocation(offset: diagnostic.offset);
var line = lines[location.lineNumber - 1];
var presentation = _markerPresentation(
line,
column: location.column,
length: diagnostic.length,
);
_addMarker(
location.lineNumber,
_GeneratedMarker.diagnostic(
offset: diagnostic.offset,
index: index,
constantName: diagnostic.diagnosticCode.constantName,
column: location.column,
length: diagnostic.length,
caretLength: presentation.caretLength,
includeExplicitLocation: presentation.includeExplicitLocation,
contextRefs: contextRefs,
message: _messageText(diagnostic.problemMessage),
),
);
}
void _addMarker(int lineNumber, _GeneratedMarker marker) {
markersByLine.putIfAbsent(lineNumber, () => []).add(marker);
}
/// Builds a caret marker line for a one-based [column] and [length].
String _caretLine(int column, int length) {
return '//${' ' * (column - 3)}${'^' * length}';
}
void _generateDiagnosticMarkers(Diagnostic diagnostic) {
var contextRefs = <int>[];
for (var contextMessage in diagnostic.contextMessages) {
if (contextMessage.filePath != diagnostic.problemMessage.filePath) {
throw StateError(
'Cannot generate a diagnostic expectation with a context message '
'in another file. Use updateExpectedDiagnosticsForFiles instead.',
);
}
var id = nextContextId++;
contextRefs.add(id);
_addContextMessageMarker(
contextMessage,
id: id,
index: nextMarkerIndex++,
);
}
_addDiagnosticMarker(
diagnostic,
index: nextMarkerIndex++,
contextRefs: contextRefs,
);
}
void _generateMarkers(List<Diagnostic> actualDiagnostics) {
var sortedDiagnostics = actualDiagnostics.toList()
..sort((first, second) => first.offset.compareTo(second.offset));
for (var diagnostic in sortedDiagnostics) {
_generateDiagnosticMarkers(diagnostic);
}
}
/// Returns where a generated marker should be written for an actual range.
({int lineNumber, int column}) _markerLocation({required int offset}) {
var location = lineInfo.getLocation(offset);
return (lineNumber: location.lineNumber, column: location.columnNumber);
}
/// Returns how [column] and [length] should be shown after [line].
///
/// Caret lines start with `//`, so columns 1 and 2 cannot be represented. A
/// zero-length range may still use a one-character caret as a visual anchor,
/// but must keep explicit `[column ...][length 0]` metadata.
({int? caretLength, bool includeExplicitLocation}) _markerPresentation(
_Line line, {
required int column,
required int length,
}) {
int? caretLength;
if (column > 2) {
if (length == 0 && column <= line.text.length + 1) {
caretLength = 1;
} else if (length > 0 && column + length - 1 <= line.text.length) {
caretLength = length;
}
}
return (
caretLength: caretLength,
includeExplicitLocation: caretLength != length,
);
}
String _writeContent() {
var buffer = StringBuffer();
var isFirstLine = true;
for (var line in lines) {
if (isFirstLine) {
isFirstLine = false;
} else {
buffer.writeln();
}
buffer.write(line.text);
var markers = markersByLine[line.number];
if (markers != null) {
markers.sort(_GeneratedMarker.compare);
({int column, int length})? currentCaret;
for (var marker in markers) {
if (marker.caretLength case var caretLength?) {
var markerCaret = (column: marker.column, length: caretLength);
if (markerCaret != currentCaret) {
buffer.writeln();
buffer.write(_caretLine(marker.column, caretLength));
currentCaret = markerCaret;
}
}
buffer.writeln();
buffer.write(marker.expectationText);
}
}
}
return buffer.toString();
}
static String _messageText(DiagnosticMessage message) {
var text = message.messageText(includeUrl: false);
text = _toPosixPaths(text).trim();
text = LineSplitter.split(text).join(r'\n');
return text;
}
static String _toPosixPaths(String message) {
return message.replaceAllMapped(RegExp(r'C:\\([a-zA-Z0-9_.\\]+)'), (match) {
var path = match.group(1)!;
var posixPath = path.replaceAll(r'\', '/');
return '/$posixPath';
});
}
}
/// Generated expectation marker text for one diagnostic or context message.
final class _GeneratedMarker {
/// The zero-based offset of the diagnostic or context message being marked.
final int offset;
/// The marker kind, used as a stable secondary sort key.
final _GeneratedMarkerKind kind;
/// The order in which this marker was generated.
final int index;
/// The one-based column of the diagnostic or context message range.
final int column;
/// The length of the visual caret marker, or `null` if none should be shown.
///
/// For zero-length diagnostics, a one-character caret is useful as a visual
/// anchor, but the exact `[column ...][length 0]` metadata must still be
/// emitted.
final int? caretLength;
/// The expectation comment inserted after the target line.
final String expectationText;
/// Creates a marker for a diagnostic context message.
factory _GeneratedMarker.context({
required int offset,
required int index,
required int id,
required int column,
required int length,
required int? caretLength,
required bool includeExplicitLocation,
required String message,
}) {
var buffer = StringBuffer();
buffer.write('// [context $id]');
if (includeExplicitLocation) {
buffer.write('[column $column][length $length]');
}
buffer.write(' $message');
return _GeneratedMarker._(
offset: offset,
kind: _GeneratedMarkerKind.context,
index: index,
column: column,
caretLength: caretLength,
expectationText: buffer.toString(),
);
}
/// Creates a marker for a diagnostic.
factory _GeneratedMarker.diagnostic({
required int offset,
required int index,
required String constantName,
required int column,
required int length,
required int? caretLength,
required bool includeExplicitLocation,
required List<int> contextRefs,
required String message,
}) {
var buffer = StringBuffer();
buffer.write('// [$constantName]');
if (includeExplicitLocation) {
buffer.write('[column $column][length $length]');
}
for (var id in contextRefs) {
buffer.write('[context $id]');
}
buffer.write(' $message');
return _GeneratedMarker._(
offset: offset,
kind: _GeneratedMarkerKind.diagnostic,
index: index,
column: column,
caretLength: caretLength,
expectationText: buffer.toString(),
);
}
_GeneratedMarker._({
required this.offset,
required this.kind,
required this.index,
required this.column,
required this.caretLength,
required this.expectationText,
});
/// Orders generated markers in the order they should appear after a line.
static int compare(_GeneratedMarker first, _GeneratedMarker second) {
var offsetResult = first.offset.compareTo(second.offset);
if (offsetResult != 0) {
return offsetResult;
}
var kindResult = first.kind.index.compareTo(second.kind.index);
if (kindResult != 0) {
return kindResult;
}
return first.index.compareTo(second.index);
}
}
enum _GeneratedMarkerKind { context, diagnostic }
/// A line of text in the input content.
final class _Line {
/// The one-based line number in the input content.
final int number;
/// The line text without the trailing newline characters.
final String text;
_Line({required this.number, required this.text});
/// Splits [content] into lines while preserving each line's offset.
///
/// Lines may end with `\r`, `\n`, or `\r\n`. The newline characters are not
/// included in [text], but they still contribute to offsets in [content].
static List<_Line> parse(String content) {
var result = <_Line>[];
var lineStart = 0;
var lineNumber = 1;
for (var index = 0; index < content.length; index++) {
var codeUnit = content.codeUnitAt(index);
if (codeUnit == 0x0D || codeUnit == 0x0A) {
result.add(
_Line(
number: lineNumber++,
text: content.substring(lineStart, index),
),
);
// Consume the `\n` in a `\r\n` line break.
if (codeUnit == 0x0D &&
index + 1 < content.length &&
content.codeUnitAt(index + 1) == 0x0A) {
index++;
}
lineStart = index + 1;
}
}
result.add(_Line(number: lineNumber, text: content.substring(lineStart)));
return result;
}
}
abstract final class _LineMarker {
/// Matches a caret marker line such as `// ^^^`.
static final _caretPattern = RegExp(r'^[ \t]*//[ \t]*\^+[ \t]*$');
/// Matches generated expectation comments for diagnostics and contexts.
///
/// The updater only needs to recognize lines to remove before regeneration.
/// It intentionally does not validate the full marker syntax.
static final _expectationPattern = RegExp(
r'^[ \t]*//[ \t]*\[(?:diag\.[A-Za-z_][A-Za-z0-9_]*|context[ \t]+[0-9]+)\]',
);
static bool isMarker(_Line line) {
return _caretPattern.hasMatch(line.text) ||
_expectationPattern.hasMatch(line.text);
}
}