| // 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); |
| } |
| } |