// Copyright 2018 Google Inc. Use of this source code is governed by an // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. import 'package:charcode/charcode.dart'; import 'package:source_maps/source_maps.dart'; import 'package:source_span/source_span.dart'; import 'package:string_scanner/string_scanner.dart'; import 'package:test/test.dart'; import 'package:tuple/tuple.dart'; import 'package:sass/sass.dart'; import 'package:sass/src/utils.dart'; import 'dart_api/test_importer.dart'; void main() { group("maps source to target for", () { group("a style rule", () { test("that's basic", () { _expectSourceMap(""" {{1}}foo {{2}}bar: baz """, """ {{1}}foo { {{2}}bar: baz; } """, """ {{1}}foo { {{2}}bar: baz; } """); }); test("with a multiline selector", () { _expectSourceMap(""" {{1}}foo, bar {{2}}bar: baz """, """ {{1}}foo, bar { {{2}}bar: baz; } """, """ {{1}}foo, {{1}}bar { {{2}}bar: baz; } """); }); test("with a property on a different line", () { _expectScssSourceMap(""" {{1}}foo { {{2}}bar: {{3}}baz; } """, """ {{1}}foo { {{2}}bar: {{3}}baz; } """); }); test("with a multiline property", () { _expectScssSourceMap(""" {{1}}foo { {{2}}bar: baz bang; } """, """ {{1}}foo { {{2}}bar: baz bang; } """); }); test("that's nested", () { _expectSourceMap(""" foo {{1}}bar {{2}}baz: bang """, """ foo { {{1}}bar { {{2}}baz: bang; } } """, """ {{1}}foo bar { {{2}}baz: bang; } """); }); test("with a nested rule and declaration", () { _expectSourceMap(""" {{1}}foo {{2}}a: b {{3}}bar {{4}}x: y """, """ {{1}}foo { {{2}}a: b; {{3}}bar { {{4}}x: y; } } """, """ {{1}}foo { {{2}}a: b; } {{3}}foo bar { {{4}}x: y; } """); }); test("with a nested declaration", () { _expectSourceMap(""" {{1}}foo {{2}}a: b {{3}}c: d """, """ {{1}}foo { {{2}}a: b { {{3}}c: d; } } """, """ {{1}}foo { {{2}}a: b; {{3}}a-c: d; } """); }); }); group("an unknown at-rule", () { test("without children", () { _expectSourceMap(""" {{1}}@foo (fblthp) """, """ {{1}}@foo (fblthp); """, """ {{1}}@foo (fblthp); """); }); group("that contains", () { test("declarations", () { _expectSourceMap(""" {{1}}@foo (fblthp) {{2}}bar: baz """, """ {{1}}@foo (fblthp) { {{2}}bar: baz; } """, """ {{1}}@foo (fblthp) { {{2}}bar: baz; } """); }); test("style rules", () { _expectSourceMap(""" {{1}}@foo (fblthp) {{2}}bar {{3}}baz: bang """, """ {{1}}@foo (fblthp) { {{2}}bar { {{3}}baz: bang; } } """, """ {{1}}@foo (fblthp) { {{2}}bar { {{3}}baz: bang; } } """); }); test("at-rules", () { _expectSourceMap(""" {{1}}@foo (fblthp) {{2}}@bar baz """, """ {{1}}@foo (fblthp) { {{2}}@bar baz; } """, """ {{1}}@foo (fblthp) { {{2}}@bar baz; } """); }); }); }); group("a comment", () { test("that covers a single line", () { _expectSourceMap(""" {{1}}/* foo bar {{2}}/* baz bang """, """ {{1}}/* foo bar */ {{2}}/* baz bang */ """, """ {{1}}/* foo bar */ {{2}}/* baz bang */ """); }); test("that covers multiple lines", () { _expectSourceMap(""" {{1}}/* foo bar baz bang """, """ {{1}}/* foo bar * baz bang */ """, """ {{1}}/* foo bar {{1}} * baz bang */ """); }); }); group("@import", () { test("with a single URL", () { _expectSourceMap(""" @import {{1}}url(foo) """, """ @import {{1}}url(foo); """, """ {{1}}@import url(foo); """); }); test("with multiple URLs", () { _expectSourceMap(""" @import {{1}}url(foo), {{2}}"bar.css" """, """ @import {{1}}url(foo), {{2}}"bar.css"; """, """ {{1}}@import url(foo); {{2}}@import "bar.css"; """); }); }); test("@keyframes", () { _expectSourceMap(""" {{1}}@keyframes name {{2}}from {{3}}top: 0px {{4}}10% {{5}}top: 10px """, """ {{1}}@keyframes name { {{2}}from { {{3}}top: 0px; } {{4}}10% { {{5}}top: 10px; } } """, """ {{1}}@keyframes name { {{2}}from { {{3}}top: 0px; } {{4}}10% { {{5}}top: 10px; } } """); }); group("@media", () { test("at the root", () { _expectSourceMap(""" {{1}}@media screen {{2}}foo {{3}}bar: baz """, """ {{1}}@media screen { {{2}}foo { {{3}}bar: baz; } } """, """ {{1}}@media screen { {{2}}foo { {{3}}bar: baz; } } """); }); test("within a style rule", () { _expectSourceMap(""" {{1}}foo {{2}}@media screen {{3}}bar: baz """, """ {{1}}foo { {{2}}@media screen { {{3}}bar: baz; } } """, """ {{2}}@media screen { {{1}}foo { {{3}}bar: baz; } } """); }); }); group("@supports", () { test("at the root", () { _expectSourceMap(""" {{1}}@supports (display: grid) {{2}}foo {{3}}bar: baz """, """ {{1}}@supports (display: grid) { {{2}}foo { {{3}}bar: baz; } } """, """ {{1}}@supports (display: grid) { {{2}}foo { {{3}}bar: baz; } } """); }); test("within a style rule", () { _expectSourceMap(""" {{1}}foo {{2}}@supports (display: grid) {{3}}bar: baz """, """ {{1}}foo { {{2}}@supports (display: grid) { {{3}}bar: baz; } } """, """ {{2}}@supports (display: grid) { {{1}}foo { {{3}}bar: baz; } } """); }); }); group("a value from a variable defined", () { group("in", () { test("a variable declaration", () { _expectScssSourceMap(r""" $var: {{1}}value; {{2}}a { {{3}}b: $var; } """, """ {{2}}a { {{3}}b: {{1}}value; } """); }); test("an @each rule", () { _expectScssSourceMap(r""" @each $var in {{1}}1 2 { {{2}}a { {{3}}b: $var; } } """, """ {{2}}a { {{3}}b: {{1}}1; } {{2}}a { {{3}}b: {{1}}2; } """); }); test("a @for rule", () { _expectScssSourceMap(r""" @for $var from {{1}}1 through 2 { {{2}}a { {{3}}b: $var; } } """, """ {{2}}a { {{3}}b: {{1}}1; } {{2}}a { {{3}}b: {{1}}2; } """); }); group("a mixin argument that is", () { test("the default value", () { _expectScssSourceMap(r""" @mixin foo($var: {{1}}1) { {{2}}b: $var; } {{3}}a { @include foo(); } """, """ {{3}}a { {{2}}b: {{1}}1; } """); }); test("passed by position", () { _expectScssSourceMap(r""" @mixin foo($var) { {{1}}b: $var; } {{2}}a { @include foo({{3}}1); } """, """ {{2}}a { {{1}}b: {{3}}1; } """); }); test("passed by name", () { _expectScssSourceMap(r""" @mixin foo($var) { {{1}}b: $var; } {{2}}a { @include foo($var: {{3}}1); } """, """ {{2}}a { {{1}}b: {{3}}1; } """); }); test("passed by arglist", () { _expectScssSourceMap(r""" @mixin foo($var) { {{1}}b: $var; } {{2}}a { @include foo({{3}}(1,)...); } """, """ {{2}}a { {{1}}b: {{3}}1; } """); }); }); }); group("in a variable which is referenced by", () { test("a variable rename", () { _expectScssSourceMap(r""" $var1: {{1}}value; $var2: $var1; {{2}}a { {{3}}b: $var2; } """, """ {{2}}a { {{3}}b: {{1}}value; } """); }); test("an @each rule from a variable", () { _expectScssSourceMap(r""" $list: {{1}}1 2; @each $var in $list { {{2}}a { {{3}}b: $var; } } """, """ {{2}}a { {{3}}b: {{1}}1; } {{2}}a { {{3}}b: {{1}}2; } """); }); test("a @for rule from a variable", () { _expectScssSourceMap(r""" $start: {{1}}1; $end: 2; @for $var from $start through $end { {{2}}a { {{3}}b: $var; } } """, """ {{2}}a { {{3}}b: {{1}}1; } {{2}}a { {{3}}b: {{1}}2; } """); }); test("a @use rule with a with clause", () { _expectScssSourceMap(r""" $var1: {{1}}new value; @use 'other' with ($var2: $var1); {{2}}a { {{3}}b: other.$var2; } """, """ {{2}}a { {{3}}b: {{1}}new value; } """, importer: TestImporter( (url) => Uri.parse("u:$url"), (_) => ImporterResult(r"$var2: default value !default;", syntax: Syntax.scss))); }); group("a mixin argument that is", () { test("the default value", () { _expectScssSourceMap(r""" $original: {{1}}1; @mixin foo($var: $original) { {{2}}b: $var; } {{3}}a { @include foo(); } """, """ {{3}}a { {{2}}b: {{1}}1; } """); }); test("passed by position", () { _expectScssSourceMap(r""" $original: {{1}}1; @mixin foo($var) { {{2}}b: $var; } {{3}}a { @include foo($original); } """, """ {{3}}a { {{2}}b: {{1}}1; } """); }); test("passed by name", () { _expectScssSourceMap(r""" $original: {{1}}1; @mixin foo($var) { {{2}}b: $var; } {{3}}a { @include foo($var: $original); } """, """ {{3}}a { {{2}}b: {{1}}1; } """); }); test("passed by arglist", () { _expectScssSourceMap(r""" $original: {{1}}1; @mixin foo($var) { {{2}}b: $var; } {{3}}a { @include foo($original...); } """, """ {{3}}a { {{2}}b: {{1}}1; } """); }); }); }); }); group("a stylesheet with Unicode characters", () { test("in expanded mode", () { _expectSourceMap(""" {{1}}föö {{2}}bär: bäz """, """ {{1}}föö { {{2}}bär: bäz; } """, """ @charset "UTF-8"; {{1}}föö { {{2}}bär: bäz; } """); }); test("in compressed mode", () { _expectSourceMap(""" {{1}}föö {{2}}bär: bäz """, """ {{1}}föö { {{2}}bär: bäz; } """, "\uFEFF{{1}}föö{{{2}}bär:bäz}", style: OutputStyle.compressed); }); }); }); test("doesn't use the source map location for variable errors", () { // When source maps are enabled (by passing a callback to sourceMap), Sass // tracks the original location where each variable was declared so that // browsers can link to variable declarations rather than just usages. // However, we want to refer to the usages when reporting errors because // they have more context. expect(() { compileString(r""" $map: (a: b); x {y: $map} """, sourceMap: (_) {}); }, throwsA(predicate((untypedError) { var error = untypedError as SourceSpanException; expect(error.span!.text, equals(r"$map")); return true; }))); }); } /// Asserts that [sass] and [scss] both compile to [css], and that the /// associated source maps are generated properly. /// /// All three strings are expected to be annotated to indicate which locations /// in the source Sass and SCSS should map to which locations in the CSS. This /// is done using numbers enclosed in double curly braces called "location /// identifiers'. For example, the source text /// /// {{1}}foo{{2}}: {{3}}1 + 1{{4}}; /// /// indicates four locations that should be mapped to the locations with the /// same numbers in the target text: /// /// {{1}}foo{{2}}: {{3}}2{{4}}; /// /// The [css] text may have multiple instances of the same location identifier, /// which indicates that the same source text is mapped to multiple different /// target locations. /// /// This also re-indents the input strings with [_reindent]. void _expectSourceMap(String sass, String scss, String css, {Importer? importer, OutputStyle? style}) { _expectSassSourceMap(sass, css, importer: importer, style: style); _expectScssSourceMap(scss, css, importer: importer, style: style); } /// Like [_expectSourceMap], but with only SCSS source. void _expectScssSourceMap(String scss, String css, {Importer? importer, OutputStyle? style}) { var scssTuple = _extractLocations(_reindent(scss)); var scssText = scssTuple.item1; var scssLocations = _tuplesToMap(scssTuple.item2); var cssTuple = _extractLocations(_reindent(css)); var cssText = cssTuple.item1; var cssLocations = cssTuple.item2; late SingleMapping scssMap; var scssOutput = compileString(scssText, sourceMap: (map) => scssMap = map, importer: importer, style: style); expect(scssOutput, equals(cssText)); _expectMapMatches(scssMap, scssText, cssText, scssLocations, cssLocations); } /// Like [_expectSourceMap], but with only indented source. void _expectSassSourceMap(String sass, String css, {Importer? importer, OutputStyle? style}) { var sassTuple = _extractLocations(_reindent(sass)); var sassText = sassTuple.item1; var sassLocations = _tuplesToMap(sassTuple.item2); var cssTuple = _extractLocations(_reindent(css)); var cssText = cssTuple.item1; var cssLocations = cssTuple.item2; late SingleMapping sassMap; var sassOutput = compileString(sassText, indented: true, sourceMap: (map) => sassMap = map, importer: importer, style: style); expect(sassOutput, equals(cssText)); _expectMapMatches(sassMap, sassText, cssText, sassLocations, cssLocations); } /// Returns [string] with leading whitespace stripped from each line so that the /// least-indented line has zero indentation. String _reindent(String string) { var lines = trimAsciiRight(string).split("\n"); var minIndent = lines .where((line) => trimAscii(line).isNotEmpty) .map((line) => line.length - trimAsciiLeft(line).length) .reduce((length1, length2) => length1 < length2 ? length1 : length2); return lines .map((line) => trimAscii(line).isEmpty ? "" : line.substring(minIndent)) .join("\n"); } /// Parses and removes the location annotations from [text]. Tuple2>> _extractLocations( String text) { var scanner = StringScanner(text); var buffer = StringBuffer(); var locations = >[]; var offset = 0; var line = 0; var column = 0; while (!scanner.isDone) { if (scanner.matches(RegExp(r"{{[^{]"))) { scanner.expect("{{"); var start = scanner.position; while (!scanner.scan("}}")) { scanner.readChar(); } locations.add(Tuple2(scanner.substring(start, scanner.position - 2), SourceLocation(offset, line: line, column: column))); } else if (scanner.scanChar($lf)) { offset++; line++; column = 0; buffer.writeln(); } else { buffer.writeCharCode(scanner.readChar()); offset++; column++; } } return Tuple2(buffer.toString(), locations); } /// Converts a list of tuples to a map, asserting that each key appears only /// once. Map _tuplesToMap(Iterable> tuples) { var map = {}; for (var tuple in tuples) { expect(map, isNot(contains(tuple.item1))); map[tuple.item1] = tuple.item2; } return map; } /// Asserts that the entries in [map] match the map given by [sourceLocations] /// and [targetLocations]. void _expectMapMatches( SingleMapping map, String sourceText, String targetText, Map sourceLocations, List> targetLocations) { expect(sourceLocations.keys, equals({for (var tuple in targetLocations) tuple.item1})); String actualMap() => "\nActual map:\n\n" + _mapToString(map, sourceText, targetText) + "\n"; var entryIter = _entriesForMap(map).iterator; for (var tuple in targetLocations) { var name = tuple.item1; var expectedTarget = tuple.item2; var expectedSource = sourceLocations[name]!; if (!entryIter.moveNext()) { fail('Missing mapping "$name", expected ' '${_mapping(expectedSource, expectedTarget)}.\n' + actualMap()); } var entry = entryIter.current; if (expectedSource.line != entry.source.line || expectedSource.column != entry.source.column || expectedTarget.line != entry.target.line || expectedTarget.column != entry.target.column) { fail('Mapping "$name" was ${_mapping(entry.source, entry.target)}, ' 'expected ${_mapping(expectedSource, expectedTarget)}.\n' + actualMap()); } } expect(entryIter.moveNext(), isFalse, reason: 'Expected no more mappings.\n' + actualMap()); } /// Converts a [map] back into [Entry]s. Iterable _entriesForMap(SingleMapping map) sync* { for (var lineEntry in map.lines) { for (var entry in lineEntry.entries) { yield Entry( SourceLocation(0, line: entry.sourceLine, column: entry.sourceColumn), SourceLocation(0, line: lineEntry.line, column: entry.column), null); } } } /// Returns a terse human-readable string for a mapping from a source /// [SourceLocation] to a target [SourceLocation]. String _mapping(SourceLocation source, SourceLocation target) => "${_location(source)} to ${_location(target)}"; /// Returns a terse human-readable string for a [SourceLocation]. String _location(SourceLocation location) => "${location.line}:${location.column}"; /// Returns a human-readable string representation of [map] between [sourceText] /// and [targetText]. String _mapToString(SingleMapping map, String sourceText, String targetText) { var entries = _entriesForMap(map); var entriesInSourceOrder = entries.toList() ..sort((entry1, entry2) => entry1.source.compareTo(entry2.source)); // A map from lines and columns in [sourceText] to the names of the entries // with those source locations. var entryNames = , String>{}; var i = 0; for (var entry in entriesInSourceOrder) { entryNames.putIfAbsent( Tuple2(entry.source.line, entry.source.column), () => (++i).toString()); } var sourceScanner = LineScanner(sourceText); var sourceBuffer = StringBuffer(); while (!sourceScanner.isDone) { var name = entryNames[Tuple2(sourceScanner.line, sourceScanner.column)]; if (name != null) sourceBuffer.write("{{$name}}"); sourceBuffer.writeCharCode(sourceScanner.readChar()); } var targetScanner = LineScanner(targetText); var targetBuffer = StringBuffer(); var entryIter = entries.iterator..moveNext(); while (!targetScanner.isDone) { var entry = entryIter.current; if (targetScanner.line == entry.target.line && targetScanner.column == entry.target.column) { var name = entryNames[Tuple2(entry.source.line, entry.source.column)]; targetBuffer.write("{{$name}}"); if (!entryIter.moveNext()) { targetBuffer.write(targetScanner.rest); break; } } targetBuffer.writeCharCode(targetScanner.readChar()); } return sourceBuffer.toString() + "\n\n" + "v" * 50 + "\n\n" + targetBuffer.toString(); }