diff --git a/pkgs/source_maps/CHANGELOG.md b/pkgs/source_maps/CHANGELOG.md index b06ac72ea..ad79d389f 100644 --- a/pkgs/source_maps/CHANGELOG.md +++ b/pkgs/source_maps/CHANGELOG.md @@ -1,5 +1,9 @@ ## 0.10.14-wip +* Fix `SingleMapping.spanFor` to use the entry from a previous line as specified + by the sourcemap specification + (https://tc39.es/ecma426/#sec-GetOriginalPositions), + ## 0.10.13 * Require Dart 3.3 diff --git a/pkgs/source_maps/lib/parser.dart b/pkgs/source_maps/lib/parser.dart index 590dfc682..2ce4d6d38 100644 --- a/pkgs/source_maps/lib/parser.dart +++ b/pkgs/source_maps/lib/parser.dart @@ -500,31 +500,37 @@ class SingleMapping extends Mapping { StateError('Invalid entry in sourcemap, expected 1, 4, or 5' ' values, but got $seen.\ntargeturl: $targetUrl, line: $line'); - /// Returns [TargetLineEntry] which includes the location in the target [line] - /// number. In particular, the resulting entry is the last entry whose line - /// number is lower or equal to [line]. - TargetLineEntry? _findLine(int line) { - var index = binarySearch(lines, (e) => e.line > line); - return (index <= 0) ? null : lines[index - 1]; - } - - /// Returns [TargetEntry] which includes the location denoted by - /// [line], [column]. If [lineEntry] corresponds to [line], then this will be - /// the last entry whose column is lower or equal than [column]. If - /// [lineEntry] corresponds to a line prior to [line], then the result will be - /// the very last entry on that line. - TargetEntry? _findColumn(int line, int column, TargetLineEntry? lineEntry) { - if (lineEntry == null || lineEntry.entries.isEmpty) return null; - if (lineEntry.line != line) return lineEntry.entries.last; - var entries = lineEntry.entries; - var index = binarySearch(entries, (e) => e.column > column); - return (index <= 0) ? null : entries[index - 1]; + /// Returns the last [TargetEntry] which includes the location denoted by + /// [line], [column]. + /// + /// This corresponds to the computation of _last_ in [GetOriginalPositions][1] + /// in the sourcemap specification. + /// + /// [1]: https://tc39.es/ecma426/#sec-GetOriginalPositions + TargetEntry? _findEntry(int line, int column) { + // To find the *last* TargetEntry, we scan backwards, starting from the + // first line after our target line, or the end of [lines]. + var lineIndex = binarySearch(lines, (e) => e.line > line); + while (--lineIndex >= 0) { + final lineEntry = lines[lineIndex]; + final entries = lineEntry.entries; + if (entries.isEmpty) continue; + // If we scan to a line before the target line, the last entry extends to + // cover our search location. + if (lineEntry.line != line) return entries.last; + final index = binarySearch(entries, (e) => e.column > column); + if (index > 0) return entries[index - 1]; + // We get here when the line has entries, but they are all after the + // column. When this happens, the line and column correspond to the + // previous entry, usually the last entry at the previous `lineIndex`. + } + return null; } @override SourceMapSpan? spanFor(int line, int column, {Map? files, String? uri}) { - var entry = _findColumn(line, column, _findLine(line)); + final entry = _findEntry(line, column); if (entry == null) return null; var sourceUrlId = entry.sourceUrlId; diff --git a/pkgs/source_maps/test/continued_region_test.dart b/pkgs/source_maps/test/continued_region_test.dart new file mode 100644 index 000000000..eb12e3012 --- /dev/null +++ b/pkgs/source_maps/test/continued_region_test.dart @@ -0,0 +1,110 @@ +// Copyright (c) 2025, 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 'package:source_maps/source_maps.dart'; +import 'package:source_span/source_span.dart'; +import 'package:test/test.dart'; + +void main() { + /// This is a test for spans of the generated file that continue over several + /// lines. + /// + /// In a sourcemap, a span continues from the start encoded position until the + /// next position, regardless of whether the second position in on the same + /// line in the generated file or a subsequent line. + void testSpans(int lineA, int columnA, int lineB, int columnB) { + // Create a sourcemap describing a 'rectangular' generated file with three + // spans, each potentially over several lines: (1) an initial span that is + // unmapped, (2) a span that maps to file 'A', the span continuing until (3) + // a span that maps to file 'B'. + // + // We can describe the mapping by an 'image' of the generated file, where + // the positions marked as 'A' in the 'image' correspond to locations in the + // generated file that map to locations in source file 'A'. Lines and + // columns are zero-based. + // + // 0123456789 + // 0: ---------- + // 1: ----AAAAAA lineA: 1, columnA: 4, i.e. locationA + // 2: AABBBBBBBB lineB: 2, columnB: 2, i.e. locationB + // 3: BBBBBBBBBB + // + // Once we have the mapping, we probe every position in a 8x10 rectangle to + // validate that it maps to the intended original source file. + + expect(isBefore(lineB, columnB, lineA, columnA), isFalse, + reason: 'Test valid only for ordered positions'); + + SourceLocation location(Uri? uri, int line, int column) { + final offset = line * 10 + column; + return SourceLocation(offset, sourceUrl: uri, line: line, column: column); + } + + // Locations in the generated file. + final uriMap = Uri.parse('output.js.map'); + final locationA = location(uriMap, lineA, columnA); + final locationB = location(uriMap, lineB, columnB); + + // Original source locations. + final sourceA = location(Uri.parse('A'), 0, 0); + final sourceB = location(Uri.parse('B'), 0, 0); + + final json = (SourceMapBuilder() + ..addLocation(sourceA, locationA, null) + ..addLocation(sourceB, locationB, null)) + .build(uriMap.toString()); + + final mapping = parseJson(json); + + // Validate by comparing 'images' of the generate file. + final expectedImage = StringBuffer(); + final actualImage = StringBuffer(); + + for (var line = 0; line < 8; line++) { + for (var column = 0; column < 10; column++) { + final span = mapping.spanFor(line, column); + final expected = isBefore(line, column, lineA, columnA) + ? '-' + : isBefore(line, column, lineB, columnB) + ? 'A' + : 'B'; + final actual = span?.start.sourceUrl?.path ?? '-'; // Unmapped -> '-'. + + expectedImage.write(expected); + actualImage.write(actual); + } + expectedImage.writeln(); + actualImage.writeln(); + } + expect(actualImage.toString(), expectedImage.toString()); + } + + test('continued span, same position', () { + testSpans(2, 4, 2, 4); + }); + + test('continued span, same line', () { + testSpans(2, 4, 2, 7); + }); + + test('continued span, next line, earlier column', () { + testSpans(2, 4, 3, 2); + }); + + test('continued span, next line, later column', () { + testSpans(2, 4, 3, 6); + }); + + test('continued span, later line, earlier column', () { + testSpans(2, 4, 5, 2); + }); + + test('continued span, later line, later column', () { + testSpans(2, 4, 5, 6); + }); +} + +bool isBefore(int line1, int column1, int line2, int column2) { + return line1 < line2 || line1 == line2 && column1 < column2; +} diff --git a/pkgs/source_maps/test/refactor_test.dart b/pkgs/source_maps/test/refactor_test.dart index 5bc3818e5..5ac239c54 100644 --- a/pkgs/source_maps/test/refactor_test.dart +++ b/pkgs/source_maps/test/refactor_test.dart @@ -126,9 +126,16 @@ void main() { ' | ^\n' " '"); - // Lines added have no mapping (they should inherit the last mapping), - // but the end of the edit region continues were we left off: - expect(_span(4, 1, map, file), isNull); + // Newly added lines had no additional mapping, so they inherit the last + // position on the previously mapped line. The end of the region continues + // where the previous mapping left off. + expect( + _span(4, 1, map, file), + 'line 3, column 6: \n' + ' ,\n' + '3 | 01*3456789\n' + ' | ^\n' + ' \''); expect( _span(4, 5, map, file), 'line 3, column 8: \n'