diff --git a/CHANGELOG.md b/CHANGELOG.md index b6afd28..c06e36c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,36 @@ +## 1.3.0 + +### Namespace Migrator + +* Add a new migrator for changing namespaces of `@use` rules. + + This migrator lets you change namespaces by matching regular expressions on + existing namespaces or on `@use` rule URLs. + + You do this by passing expressions to the `--rename` in one of the following + forms: + + * ` to `: The `` regular + expression matches the entire existing namespace, and `` is + the replacement. + + * `url to `: The `` regular + expression matches the entire URL in the `@use` rule, and `` + is the namespace that's chosen for it. + + The `` patterns can include references to [captured groups][] + from the matching regular expression (e.g. `\1`). + + [captured groups]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Groups_and_Ranges + + You can pass `--rename` multiple times and they will be checked in order until + one matches (at which point subsequent renames will be ignored). You can also + separate multiple rename expressions with semicolons or line breaks. + + By default, if the renaming results in a conflict between multiple `@use` + rules, the migration will fail, but you can force it to resolve conflicts with + numerical suffixes by passing `--force`. + ## 1.2.6 ### Module Migrator diff --git a/lib/src/migration_visitor.dart b/lib/src/migration_visitor.dart index 6bd73c7..5b554c9 100644 --- a/lib/src/migration_visitor.dart +++ b/lib/src/migration_visitor.dart @@ -66,7 +66,7 @@ abstract class MigrationVisitor extends RecursiveAstVisitor { Importer get importer => _importer; Importer _importer; - MigrationVisitor(this.importCache, {this.migrateDependencies = true}); + MigrationVisitor(this.importCache, this.migrateDependencies); /// Runs a new migration on [stylesheet] (and its dependencies, if /// [migrateDependencies] is true) and returns a map of migrated contents. diff --git a/lib/src/migrators/division.dart b/lib/src/migrators/division.dart index faa964e..88bb7d1 100644 --- a/lib/src/migrators/division.dart +++ b/lib/src/migrators/division.dart @@ -56,7 +56,7 @@ class _DivisionMigrationVisitor extends MigrationVisitor { _DivisionMigrationVisitor( ImportCache importCache, this.isPessimistic, bool migrateDependencies) - : super(importCache, migrateDependencies: migrateDependencies); + : super(importCache, migrateDependencies); /// True when division is allowed by the context the current node is in. var _isDivisionAllowed = false; diff --git a/lib/src/migrators/module.dart b/lib/src/migrators/module.dart index 6f1d27a..55ee961 100644 --- a/lib/src/migrators/module.dart +++ b/lib/src/migrators/module.dart @@ -93,9 +93,8 @@ class ModuleMigrator extends Migrator { } var references = References(importCache, stylesheet, importer); - var visitor = _ModuleMigrationVisitor( - importCache, references, globalResults['load-path'] as List, - migrateDependencies: migrateDependencies, + var visitor = _ModuleMigrationVisitor(importCache, references, + globalResults['load-path'] as List, migrateDependencies, prefixesToRemove: (argResults['remove-prefix'] as List) ?.map((prefix) => prefix.replaceAll('_', '-')), forwards: forwards); @@ -204,17 +203,15 @@ class _ModuleMigrationVisitor extends MigrationVisitor { /// the module migrator will filter out the dependencies' migration results. /// /// This converts the OS-specific relative [loadPaths] to absolute URL paths. - _ModuleMigrationVisitor( - this.importCache, this.references, List loadPaths, - {bool migrateDependencies, - Iterable prefixesToRemove, - this.forwards}) + _ModuleMigrationVisitor(this.importCache, this.references, + List loadPaths, bool migrateDependencies, + {Iterable prefixesToRemove, this.forwards}) : loadPaths = List.unmodifiable( loadPaths.map((path) => p.toUri(p.absolute(path)).path)), prefixesToRemove = prefixesToRemove == null ? const {} : UnmodifiableSetView(prefixesToRemove.toSet()), - super(importCache, migrateDependencies: migrateDependencies); + super(importCache, migrateDependencies); /// Checks which global declarations need to be renamed, then runs the /// migrator. diff --git a/lib/src/migrators/namespace.dart b/lib/src/migrators/namespace.dart new file mode 100644 index 0000000..4121494 --- /dev/null +++ b/lib/src/migrators/namespace.dart @@ -0,0 +1,207 @@ +// Copyright 2021 Google LLC +// +// 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:args/args.dart'; +import 'package:sass/sass.dart'; +import 'package:source_span/source_span.dart'; + +// The sass package's API is not necessarily stable. It is being imported with +// the Sass team's explicit knowledge and approval. See +// https://github.com/sass/dart-sass/issues/236. +import 'package:sass/src/ast/sass.dart'; +import 'package:sass/src/exception.dart'; +import 'package:sass/src/import_cache.dart'; + +import '../migration_visitor.dart'; +import '../migrator.dart'; +import '../patch.dart'; +import '../utils.dart'; +import '../renamer.dart'; + +/// Changes namespaces for `@use` rules within the file(s) being migrated. +class NamespaceMigrator extends Migrator { + final name = "namespace"; + final description = "Change namespaces for `@use` rules."; + + @override + final argParser = ArgParser() + ..addMultiOption('rename', + abbr: 'r', + splitCommas: false, + help: 'e.g. "old-namespace to new-namespace" or\n' + ' "url my/url to new-namespace"\n' + 'See https://sass-lang.com/documentation/cli/migrator#rename.') + ..addFlag('force', + abbr: 'f', + help: 'Force rename namespaces, adding numerical suffixes for ' + 'conflicts.'); + + @override + Map migrateFile( + ImportCache importCache, Stylesheet stylesheet, Importer importer) { + var renamer = Renamer(argResults['rename'].join('\n'), + {'': (rule) => rule.namespace, 'url': (rule) => rule.url.toString()}, + sourceUrl: '--rename'); + var visitor = _NamespaceMigrationVisitor( + renamer, argResults['force'] as bool, importCache, migrateDependencies); + var result = visitor.run(stylesheet, importer); + missingDependencies.addAll(visitor.missingDependencies); + return result; + } +} + +class _NamespaceMigrationVisitor extends MigrationVisitor { + final Renamer renamer; + final bool forceRename; + + /// A set of spans for each *original* namespace in the current file. + /// + /// Each span covers just the namespace of a member reference. + Map> _spansByNamespace; + + /// The set of namespaces used in the current file *after* renaming. + Set _usedNamespaces; + + _NamespaceMigrationVisitor(this.renamer, this.forceRename, + ImportCache importCache, bool migrateDependencies) + : super(importCache, migrateDependencies); + + @override + void visitStylesheet(Stylesheet node) { + var oldSpansByNamespace = _spansByNamespace; + var oldUsedNamespaces = _usedNamespaces; + _spansByNamespace = {}; + _usedNamespaces = {}; + super.visitStylesheet(node); + _spansByNamespace = oldSpansByNamespace; + _usedNamespaces = oldUsedNamespaces; + } + + @override + void beforePatch(Stylesheet node) { + // Pass each `@use` rule through the renamer. + var newNamespaces = >{}; + for (var rule in node.children.whereType()) { + if (rule.namespace == null) continue; + newNamespaces + .putIfAbsent(renamer.rename(rule) ?? rule.namespace, () => {}) + .add(rule); + } + + // Goes through each new namespace, resolving conflicts if necessary. + for (var entry in newNamespaces.entries) { + var newNamespace = entry.key; + var rules = entry.value; + if (rules.length == 1) { + _patchNamespace(rules.first, newNamespace); + continue; + } + + // If there's still a conflict, fail unless --force is passed. + if (!forceRename) { + throw MultiSpanSassException( + 'Rename failed. ${rules.length} rules would use namespace ' + '"$newNamespace".\n' + 'Run with --force to rename with numerical suffixes.', + rules.first.span, + '', + {for (var rule in rules.skip(1)) rule.span: ''}); + } + + // With --force, give the first rule its preferred namespace and then + // add numerical suffixes to the rest. + var suffix = 2; + for (var rule in rules) { + var forcedNamespace = newNamespace; + while (_usedNamespaces.contains(forcedNamespace)) { + forcedNamespace = '$newNamespace$suffix'; + suffix++; + } + _patchNamespace(rule, forcedNamespace); + } + } + } + + /// Patch [rule] and all references to it with [newNamespace]. + void _patchNamespace(UseRule rule, String newNamespace) { + _usedNamespaces.add(newNamespace); + if (rule.namespace == newNamespace) return; + var asClause = + RegExp('\\s*as\\s+(${rule.namespace})').firstMatch(rule.span.text); + if (asClause == null) { + // Add an `as` clause to a rule that previously lacked one. + var end = RegExp(r"""@use\s("|').*?\1""").firstMatch(rule.span.text).end; + addPatch( + Patch.insert(rule.span.subspan(0, end).end, ' as $newNamespace')); + } else if (namespaceForPath(rule.url.toString()) == newNamespace) { + // Remove an `as` clause that is no longer necessary. + addPatch( + patchDelete(rule.span, start: asClause.start, end: asClause.end)); + } else { + // Change the namespace of an existing `as` clause. + addPatch(Patch( + rule.span.subspan(asClause.end - rule.namespace.length, asClause.end), + newNamespace)); + } + for (FileSpan span in _spansByNamespace[rule.namespace] ?? {}) { + addPatch(Patch(span, newNamespace)); + } + } + + /// If [namespace] is not null, add its span to [_spansByNamespace]. + void _addNamespaceSpan(String namespace, FileSpan span) { + if (namespace != null) { + assert(span.text.startsWith(namespace)); + _spansByNamespace + .putIfAbsent(namespace, () => {}) + .add(subspan(span, end: namespace.length)); + } + } + + @override + void visitFunctionExpression(FunctionExpression node) { + _addNamespaceSpan(node.namespace, node.span); + var name = node.name.asPlain; + if (name == 'get-function') { + var moduleArg = node.arguments.named['module']; + if (node.arguments.positional.length == 3) { + moduleArg ??= node.arguments.positional[2]; + } + if (moduleArg is StringExpression) { + var namespace = moduleArg.text.asPlain; + if (namespace != null) { + var span = moduleArg.hasQuotes + ? moduleArg.span.subspan(1, moduleArg.span.length - 1) + : moduleArg.span; + _addNamespaceSpan(namespace, span); + } + } + } + super.visitFunctionExpression(node); + } + + @override + void visitIncludeRule(IncludeRule node) { + if (node.namespace != null) { + var startNamespace = node.span.text.indexOf( + node.namespace, node.span.text[0] == '+' ? 1 : '@include'.length); + _addNamespaceSpan(node.namespace, node.span.subspan(startNamespace)); + } + super.visitIncludeRule(node); + } + + @override + void visitVariableDeclaration(VariableDeclaration node) { + _addNamespaceSpan(node.namespace, node.span); + super.visitVariableDeclaration(node); + } + + @override + void visitVariableExpression(VariableExpression node) { + _addNamespaceSpan(node.namespace, node.span); + super.visitVariableExpression(node); + } +} diff --git a/lib/src/renamer.dart b/lib/src/renamer.dart new file mode 100644 index 0000000..e2179ae --- /dev/null +++ b/lib/src/renamer.dart @@ -0,0 +1,256 @@ +// Copyright 2021 Google LLC +// +// 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:string_scanner/string_scanner.dart'; + +/// Renamer is a small DSL for renaming things. +/// +/// To create a Renamer, you provide a piece of code and a map of keys to +/// functions that return some string for each key based on an input. +/// +/// To rename, you pass some input to Renamer. It will return a string with the +/// new name if the code provided a match, or null if no match was found for +/// that input. +/// +/// The language itself consists of a series of statements separated by +/// semicolons or line breaks. Each statement has 3 clauses: the key clause, +/// the matcher clause, and the output clause. +/// +/// The key clause specifies which key to match on. This can be empty to refer +/// to a key that's just the empty string. +/// +/// The matcher clause is a regular expression that should match the entirety of +/// the value of that key. +/// +/// The output clause is the text that should be returned when the matcher +/// succeeds and may contain references to captured groups in the matcher's +/// regular expression. +/// +/// For an input, statements are evaluated in order until a match is found. Once +/// a match is found, its output is returned immediately, bypassing any +/// subsequent statements. +/// +/// These clauses are combined as ` to `. If the key in +/// question is the empty string, you omit that clause, so you get +/// ` to `. +/// +/// If you wish to include a semicolon, space, or literal backslash in the +/// matcher or output clause, you should escape it with `\`. +class Renamer { + /// A map from keys to functions that take an input and return the value of + /// that key for that input. + final Map keys; + + /// The list of statements that are evaluated in order by this renamer. + final List<_Statement> _statements; + + Renamer._(this.keys, this._statements); + + /// Creates a simple Renamer from [code] that only uses an empty key. + /// + /// If provided, [sourceUrl] will appear in parsing errors. It can be + /// a [String] or a [Uri]. + static Renamer simple(String code, {dynamic sourceUrl}) { + return Renamer(code, {'': (input) => input}, sourceUrl: sourceUrl); + } + + /// Creates a Renamer from [code] that takes a map from keys to string values + /// as input. + /// + /// [keys] is the list of keys that can be referenced in [code] and must + /// all appear in every input map. + /// + /// If provided, [sourceUrl] will appear in parsing errors. It can be + /// a [String] or a [Uri]. + static Renamer> map(String code, List keys, + {dynamic sourceUrl}) { + return Renamer(code, {for (var key in keys) key: (input) => input[key]}, + sourceUrl: sourceUrl); + } + + /// Creates a Renamer based on [code] and a map from keys to functions that + /// take an input and return a string. + /// + /// All keys must consist of only lowercase letters, underscores, and hyphens. + /// + /// If provided, [sourceUrl] will appear in parsing errors. It can be + /// a [String] or a [Uri]. + factory Renamer(String code, Map keys, + {dynamic sourceUrl}) { + for (var key in keys.keys) { + if (!RegExp(r'^[a-z_-]*$').hasMatch(key)) { + throw ArgumentError( + 'Invalid key "$key". Must use only lowercase letters, ' + 'underscores, and hyphens.'); + } + } + var scanner = StringScanner(code, sourceUrl: sourceUrl); + var statements = <_Statement>[]; + scanner.scan(_statementDelimiter); + while (!scanner.isDone) { + statements.add(_readStatement(scanner, keys)); + } + return Renamer._(keys, statements); + } + + /// Reads the next statement (and the trailing delimiter, if any) from + /// [scanner]. + static _Statement _readStatement( + StringScanner scanner, Map keys) { + var start = scanner.position; + FormatException lastException; + // Tries each key in succession until one is successfully returned. + for (var entry in keys.entries) { + try { + var statement = _tryKey(scanner, entry.key, entry.value); + if (statement != null) return statement; + } on FormatException catch (e) { + // While `_tryKey` will return null immediately if the statement doesn't + // start with the given key, there's a chance that a matcher for the + // default key will match a named key, so we catch the exception and + // reset the scanner's position if `_tryKey` starts consuming the + // statement before realizing the key doesn't work. + lastException = e; + scanner.position = start; + } + } + if (lastException == null) { + scanner.error('invalid key'); + } + throw lastException; + } + + /// Tries to read a statement for [key] from [scanner]. + /// + /// If [key] is non-null and the next text in [scanner] is not that key, this + /// returns null immediately, since the parse error would not be useful. + /// + /// Otherwise, after consuming the key clause (if any), attempts to read + /// the matcher and output clauses, throwing if it's unable to. + static _Statement _tryKey( + StringScanner scanner, String key, String Function(T input) keyFunction) { + if (key.isNotEmpty && !scanner.scan('$key ')) return null; + var matcher = _readMatcher(scanner); + scanner.expect(' to '); + var output = _readOutput(scanner); + return _Statement(keyFunction, matcher, output); + } + + /// Reads a matcher clause and its trailing space from [scanner]. + static RegExp _readMatcher(StringScanner scanner) { + var src = StringBuffer(); + while (true) { + var char = scanner.peekChar(); + if (char == $space) break; + if (char == $semicolon || char == $lf) { + scanner.error('statement ended unexpectedly'); + } + scanner.readChar(); + if (char == $backslash) { + var next = scanner.readChar(); + if (next == $semicolon || next == $space) { + src.writeCharCode(next); + } else { + // If we don't capture the escape here, let regex parser handle it. + src.writeCharCode($backslash); + src.writeCharCode(next); + } + } else { + src.writeCharCode(char); + } + } + return RegExp('^$src\$'); + } + + /// Reads an output clause and the statement's trailing delimiter (if any). + static List<_OutputComponent> _readOutput(StringScanner scanner) { + var components = <_OutputComponent>[]; + var buffer = StringBuffer(); + while (true) { + var char = scanner.peekChar(); + if ({null, $space, $semicolon, $lf}.contains(char)) break; + scanner.readChar(); + if (char == $backslash) { + var next = scanner.readChar(); + if (next >= $0 && next <= $9) { + if (buffer.isNotEmpty) components.add(_Literal(buffer.toString())); + components.add(_Backreference(next - $0)); + buffer.clear(); + } else { + buffer.writeCharCode(next); + } + } else { + buffer.writeCharCode(char); + } + } + if (buffer.isNotEmpty) components.add(_Literal(buffer.toString())); + if (!scanner.isDone) { + scanner.expect(_statementDelimiter, name: 'end of statement'); + } + return components; + } + + /// Runs this renamer based on [input]. + String rename(T input) { + for (var statement in _statements) { + var result = statement.rename(input); + if (result != null) return result; + } + return null; + } +} + +// Regex that matches at least one line break or semicolon as well as any +// number of spaces in any order. +final _statementDelimiter = RegExp(r' *((\n|;) *)+'); + +/// A Renamer statement, which defines a single key and regex to match on and +/// the output to return if an input is successfully matched. +class _Statement { + /// The key this statement matches on. + final String Function(T input) key; + + /// The regular expression that matches on key. + final RegExp matcher; + + /// The output of this statement is constructed from the concatenation of + /// these components. + final List<_OutputComponent> output; + + _Statement(this.key, this.matcher, this.output); + + /// Return the output if this statement matches [input] or null otherwise. + String rename(T input) { + var match = matcher.firstMatch(key(input)); + if (match == null) return null; + return output.map((item) => item.build(match)).join(); + } +} + +/// A component of an output clause. +abstract class _OutputComponent { + /// When constructing the output, this will be called with the match that + /// the matcher clause found. + String build(RegExpMatch match); +} + +/// Literal text that's part of a statement's output. +class _Literal extends _OutputComponent { + final String text; + _Literal(this.text); + + /// This just returns the literal text of this component. + String build(RegExpMatch match) => text; +} + +/// A backreference that's part of a statement's output. +class _Backreference extends _OutputComponent { + final int number; + _Backreference(this.number); + + /// Returns the captured group numbered [number] in [match]. + String build(RegExpMatch match) => match.group(number); +} diff --git a/lib/src/runner.dart b/lib/src/runner.dart index a374640..f356843 100644 --- a/lib/src/runner.dart +++ b/lib/src/runner.dart @@ -15,6 +15,7 @@ import 'package:term_glyph/term_glyph.dart' as glyph; import 'io.dart'; import 'migrators/division.dart'; import 'migrators/module.dart'; +import 'migrators/namespace.dart'; import 'exception.dart'; /// A command runner that runs a migrator based on provided arguments. @@ -53,6 +54,7 @@ class MigratorRunner extends CommandRunner> { help: 'Print the version of the Sass migrator.', negatable: false); addCommand(DivisionMigrator()); addCommand(ModuleMigrator()); + addCommand(NamespaceMigrator()); } /// Runs a migrator and then writes the migrated files to disk unless diff --git a/pubspec.yaml b/pubspec.yaml index a53c02c..5396f3b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass_migrator -version: 1.2.6 +version: 1.3.0 description: A tool for running migrations on Sass files author: Jennifer Thakar homepage: https://github.com/sass/migrator @@ -19,6 +19,7 @@ dependencies: path: "^1.6.0" sass: "^1.24.3" source_span: "^1.4.0" + string_scanner: "^1.0.5" term_glyph: "^1.1.0" tuple: "^1.0.2" @@ -30,7 +31,6 @@ dev_dependencies: http: ">=0.11.0 <0.13.0" node_preamble: "^1.3.0" pub_semver: "^1.0.0" - string_scanner: "^1.0.0" test: ">=0.12.29 <2.0.0" test_descriptor: "^1.1.1" test_process: "^1.0.0" diff --git a/test/migrators/namespace/as_clauses.hrx b/test/migrators/namespace/as_clauses.hrx new file mode 100644 index 0000000..a119761 --- /dev/null +++ b/test/migrators/namespace/as_clauses.hrx @@ -0,0 +1,14 @@ +<==> arguments +--rename 'a to x' +--rename 'b to y' +--rename 'c to z' + +<==> input/entrypoint.scss +@use "a"; // default -> as +@use "not-b" as b; // change as +@use "z" as c; // as -> default + +<==> output/entrypoint.scss +@use "a" as x; // default -> as +@use "not-b" as y; // change as +@use "z"; // as -> default diff --git a/test/migrators/namespace/conflict_force.hrx b/test/migrators/namespace/conflict_force.hrx new file mode 100644 index 0000000..1221686 --- /dev/null +++ b/test/migrators/namespace/conflict_force.hrx @@ -0,0 +1,21 @@ +<==> arguments +--rename 'library.* to library' +--force + +<==> input/entrypoint.scss +@use "library-a"; +@use "library-b"; + +a { + b: library-a.$variable; + c: library-b.$variable; +} + +<==> output/entrypoint.scss +@use "library-a" as library; +@use "library-b" as library2; + +a { + b: library.$variable; + c: library2.$variable; +} diff --git a/test/migrators/namespace/conflict_no_force.hrx b/test/migrators/namespace/conflict_no_force.hrx new file mode 100644 index 0000000..11e8ac4 --- /dev/null +++ b/test/migrators/namespace/conflict_no_force.hrx @@ -0,0 +1,23 @@ +<==> arguments +--rename 'library.* to library' + +<==> input/entrypoint.scss +@use "library-a"; +@use "library-b"; + +a { + b: library-a.$variable; + c: library-b.$variable; +} + +<==> error.txt +Error: Rename failed. 2 rules would use namespace "library". +Run with --force to rename with numerical suffixes. + , +1 | @use "library-a"; + | ^^^^^^^^^^^^^^^^ +2 | @use "library-b"; + | ================ + ' + entrypoint.scss 1:1 root stylesheet +Migration failed! diff --git a/test/migrators/namespace/invalid_rename.hrx b/test/migrators/namespace/invalid_rename.hrx new file mode 100644 index 0000000..490b3b5 --- /dev/null +++ b/test/migrators/namespace/invalid_rename.hrx @@ -0,0 +1,13 @@ +<==> arguments +--rename invalid + +<==> input/entrypoint.scss +// nothing + +<==> error.txt +Error on line 1, column 8 of --rename: expected more input. + , +1 | invalid + | ^ + ' +Migration failed! diff --git a/test/migrators/namespace/multiple_renames.hrx b/test/migrators/namespace/multiple_renames.hrx new file mode 100644 index 0000000..32830e6 --- /dev/null +++ b/test/migrators/namespace/multiple_renames.hrx @@ -0,0 +1,27 @@ +<==> arguments +--rename 'library-a to library' +--rename 'library-(.*) to \1' +--rename 'library-b to library' +--rename 'library to something-random' + +<==> README +This test ensures that renames are tested in order, with the first one that +matches being used (and no namespace can be renamed multiple times). + +<==> input/entrypoint.scss +@use "library-a"; +@use "library-b"; + +a { + a: library-a.$variable; + b: library-b.$variable; +} + +<==> output/entrypoint.scss +@use "library-a" as library; +@use "library-b" as b; + +a { + a: library.$variable; + b: b.$variable; +} diff --git a/test/migrators/namespace/rename_by_url.hrx b/test/migrators/namespace/rename_by_url.hrx new file mode 100644 index 0000000..846a173 --- /dev/null +++ b/test/migrators/namespace/rename_by_url.hrx @@ -0,0 +1,24 @@ +<==> arguments +--rename 'url (.*)/(\w+)/lib/mixins to \2' + +<==> input/entrypoint.scss +@use "some/path/button/lib/mixins"; +@use "some/path/input/lib/mixins" as mixins2; +@use "some/path/table/lib/mixins" as mixins3; + +a { + @include mixins.styles; + @include mixins2.styles; + @include mixins3.styles; +} + +<==> output/entrypoint.scss +@use "some/path/button/lib/mixins" as button; +@use "some/path/input/lib/mixins" as input; +@use "some/path/table/lib/mixins" as table; + +a { + @include button.styles; + @include input.styles; + @include table.styles; +} diff --git a/test/migrators/namespace/simple_rename.hrx b/test/migrators/namespace/simple_rename.hrx new file mode 100644 index 0000000..e755e97 --- /dev/null +++ b/test/migrators/namespace/simple_rename.hrx @@ -0,0 +1,26 @@ +<==> arguments +--rename 'a to b' + +<==> input/entrypoint.scss +@use "meta"; +@use "library" as a; + +a { + b: a.$variable; + c: a.function(); + d: meta.get-function('function', $module: 'a'); + e: meta.get-function(function, false, a); + @include a.mixin; +} + +<==> output/entrypoint.scss +@use "meta"; +@use "library" as b; + +a { + b: b.$variable; + c: b.function(); + d: meta.get-function('function', $module: 'b'); + e: meta.get-function(function, false, b); + @include b.mixin; +} diff --git a/test/migrators/namespace_dart_test.dart b/test/migrators/namespace_dart_test.dart new file mode 100644 index 0000000..350cdf6 --- /dev/null +++ b/test/migrators/namespace_dart_test.dart @@ -0,0 +1,11 @@ +// Copyright 2021 Google LLC +// +// 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 '../utils.dart'; + +main() { + testMigrator("namespace"); +} diff --git a/test/migrators/namespace_node_test.dart b/test/migrators/namespace_node_test.dart new file mode 100644 index 0000000..9d77f50 --- /dev/null +++ b/test/migrators/namespace_node_test.dart @@ -0,0 +1,16 @@ +// Copyright 2021 Google LLC +// +// 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. + +@Tags(["node"]) + +import 'package:test/test.dart'; + +import '../utils.dart'; + +main() { + runNodeTests = true; + testMigrator("division"); +} diff --git a/test/renamer_test.dart b/test/renamer_test.dart new file mode 100644 index 0000000..cf25c1f --- /dev/null +++ b/test/renamer_test.dart @@ -0,0 +1,175 @@ +// Copyright 2021 Google LLC +// +// 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:sass_migrator/src/renamer.dart'; +import 'package:test/test.dart'; + +void main() { + group('single statements', () { + group('single key', () { + test('simple rename', () { + var renamer = Renamer.simple('old to new'); + expect(renamer.rename('old'), equals('new')); + expect(renamer.rename('oldx'), isNull); + expect(renamer.rename('xold'), isNull); + expect(renamer.rename('new'), isNull); + }); + + test(r'backreference to entire match', () { + var renamer = Renamer.simple(r'.+ to prefix-\0'); + expect(renamer.rename('a'), equals('prefix-a')); + expect(renamer.rename('abc'), equals('prefix-abc')); + }); + + test(r'backreference to group', () { + var renamer = Renamer.simple(r'(.+)-suffix to \1'); + expect(renamer.rename('a-suffix'), equals('a')); + expect(renamer.rename('abc-suffix'), equals('abc')); + expect(renamer.rename('abc'), isNull); + expect(renamer.rename('-suffix'), isNull); + }); + }); + + group('escapes', () { + test(r'spaces', () { + var renamer = Renamer.simple(r'a\ b to x\ y'); + expect(renamer.rename('a b'), equals('x y')); + }); + + test(r'semicolons', () { + var renamer = Renamer.simple(r'a\;b to x\;y'); + expect(renamer.rename('a;b'), equals('x;y')); + }); + + test(r'backslash at start', () { + var renamer = Renamer.simple(r'\\ab to \\xy'); + expect(renamer.rename(r'\ab'), equals(r'\xy')); + }); + + test(r'backslash in middle', () { + var renamer = Renamer.simple(r'a\\b to x\\y'); + expect(renamer.rename(r'a\b'), equals(r'x\y')); + }); + + test(r'backslash at end', () { + var renamer = Renamer.simple(r'ab\\ to xy\\'); + expect(renamer.rename(r'ab\'), equals(r'xy\')); + }); + + test(r'backslash followed by escaped space', () { + var renamer = Renamer.simple(r'ab\\\ to x\\\ y'); + expect(renamer.rename(r'ab\ '), equals(r'x\ y')); + }); + }); + + group('multiple keys', () { + test('named key', () { + var renamer = + Renamer.map(r'url .*/(\w+)/lib/mixins to \1', ['namespace', 'url']); + expect( + renamer.rename( + {'namespace': 'mixins', 'url': 'path/button/lib/mixins'}), + equals('button')); + }); + + test('named key with unused default key', () { + var renamer = + Renamer.map(r'url .*/(\w+)/lib/mixins to \1', ['', 'url']); + expect(renamer.rename({'': 'mixins', 'url': 'path/button/lib/mixins'}), + equals('button')); + }); + + test('default key', () { + var renamer = + Renamer.map(r'.*/(\w+)/lib/mixins to \1', ['namespace', '']); + expect( + renamer + .rename({'namespace': 'mixins', '': 'path/button/lib/mixins'}), + equals('button')); + }); + + test('matcher on default key has same name as another key', () { + var renamer = Renamer.map('key to to', ['', 'key']); + expect(renamer.rename({'': 'key', 'key': 'x'}), equals('to')); + }); + + test('matcher on named key is `to`', () { + var renamer = Renamer.map('key to to new', ['', 'key']); + expect(renamer.rename({'': 'key', 'key': 'to'}), equals('new')); + }); + }); + }); + + group('multiple statements', () { + test('separated by semicolon', () { + var renamer = Renamer.simple('a to b; x to y'); + expect(renamer.rename('a'), equals('b')); + expect(renamer.rename('b'), isNull); + expect(renamer.rename('x'), equals('y')); + expect(renamer.rename('y'), isNull); + }); + + test('separated by line break', () { + var renamer = Renamer.simple('a to b\nx to y'); + expect(renamer.rename('a'), equals('b')); + expect(renamer.rename('b'), isNull); + expect(renamer.rename('x'), equals('y')); + expect(renamer.rename('y'), isNull); + }); + + test('empty statements', () { + var renamer = Renamer.simple('\n;\n;a to b; ;;\n; \n;; x to y ;\n;'); + expect(renamer.rename('a'), equals('b')); + expect(renamer.rename('b'), isNull); + expect(renamer.rename('x'), equals('y')); + expect(renamer.rename('y'), isNull); + }); + + test('separated by semicolon and line break', () { + var renamer = Renamer.simple('a to b;\nx to y;'); + expect(renamer.rename('a'), equals('b')); + expect(renamer.rename('b'), isNull); + expect(renamer.rename('x'), equals('y')); + expect(renamer.rename('y'), isNull); + }); + + test('only first matching statement is applied', () { + var renamer = Renamer.simple('.* to all; old to wrong; all to wrong'); + expect(renamer.rename('old'), equals('all')); + expect(renamer.rename('wrong'), equals('all')); + expect(renamer.rename('all'), equals('all')); + }); + + test('no statements', () { + var renamer = Renamer.simple(''); + expect(renamer.rename('abc'), isNull); + }); + }); + + group('invalid syntax', () { + test('too few clauses', () { + expect(() => Renamer.simple('old new'), throwsFormatException); + }); + + test('three clauses but not `to` ', () { + expect(() => Renamer.simple('old xx new'), throwsFormatException); + }); + + test('four clauses with only default key', () { + expect(() => Renamer.simple('key old to new'), throwsFormatException); + }); + + test('four clauses with invalid key', () { + expect(() => Renamer.map('wrong old to new', ['key']), + throwsFormatException); + }); + + test('five clauses', () { + expect(() => Renamer.map('key old to new extra', ['key']), + throwsFormatException); + }); + }); +} diff --git a/test/utils.dart b/test/utils.dart index f6e745b..774eb9d 100644 --- a/test/utils.dart +++ b/test/utils.dart @@ -132,10 +132,19 @@ class _HrxTestFiles { "given test."; } } else if (filename == "arguments") { - arguments = contents.trim().split(" "); + arguments = [ + for (var match in _argParseRegex.allMatches(contents)) + match.group(1) ?? match.group(2) ?? match.group(3) + ]; } } + /// Matches arguments, including quoted strings (but not escapes). + /// + /// To get the actual argument, you need to check groups 1, 2, and 3 (for + /// double-quoted, single-quoted, and unquoted strings respectively). + final _argParseRegex = RegExp(r'''"([^"]+)"|'([^']+)'|([^'"\s][^\s]*)'''); + /// Unpacks this test's input files into a temporary directory. Future unpack() async { for (var file in input.keys) {