Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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 file 'A'. Lines and
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: s/file file/file/

// 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