Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pkgs/source_maps/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
46 changes: 26 additions & 20 deletions pkgs/source_maps/lib/parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, SourceFile>? files, String? uri}) {
var entry = _findColumn(line, column, _findLine(line));
final entry = _findEntry(line, column);
if (entry == null) return null;

var sourceUrlId = entry.sourceUrlId;
Expand Down
110 changes: 110 additions & 0 deletions pkgs/source_maps/test/continued_region_test.dart
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 10 additions & 3 deletions pkgs/source_maps/test/refactor_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down