From 2d959de063290309f9a5b9588c5ec2a481f44155 Mon Sep 17 00:00:00 2001 From: Jennifer Thakar Date: Mon, 8 Feb 2021 14:44:11 -0800 Subject: [PATCH] Adds the namespace migrator (#173) This allows you to change namespaces for `@use` rules by matching regular expressions on the existing namespace or on the rule URL. The renaming itself is specified through a small DSL. The basics of this are documented in the changelog and the help text, but I'll add proper documentation to the website before merging this and releasing 1.3.0. The tests under `test/migrators/namespace` focus on conflict resolution and the actual patching of source files, while the DSL itself is mostly tested in the unit tests in `test/renamer_test.dart`. --- CHANGELOG.md | 33 +++ lib/src/migration_visitor.dart | 2 +- lib/src/migrators/division.dart | 2 +- lib/src/migrators/module.dart | 15 +- lib/src/migrators/namespace.dart | 207 ++++++++++++++ lib/src/renamer.dart | 256 ++++++++++++++++++ lib/src/runner.dart | 2 + pubspec.yaml | 4 +- test/migrators/namespace/as_clauses.hrx | 14 + test/migrators/namespace/conflict_force.hrx | 21 ++ .../migrators/namespace/conflict_no_force.hrx | 23 ++ test/migrators/namespace/invalid_rename.hrx | 13 + test/migrators/namespace/multiple_renames.hrx | 27 ++ test/migrators/namespace/rename_by_url.hrx | 24 ++ test/migrators/namespace/simple_rename.hrx | 26 ++ test/migrators/namespace_dart_test.dart | 11 + test/migrators/namespace_node_test.dart | 16 ++ test/renamer_test.dart | 175 ++++++++++++ test/utils.dart | 11 +- 19 files changed, 868 insertions(+), 14 deletions(-) create mode 100644 lib/src/migrators/namespace.dart create mode 100644 lib/src/renamer.dart create mode 100644 test/migrators/namespace/as_clauses.hrx create mode 100644 test/migrators/namespace/conflict_force.hrx create mode 100644 test/migrators/namespace/conflict_no_force.hrx create mode 100644 test/migrators/namespace/invalid_rename.hrx create mode 100644 test/migrators/namespace/multiple_renames.hrx create mode 100644 test/migrators/namespace/rename_by_url.hrx create mode 100644 test/migrators/namespace/simple_rename.hrx create mode 100644 test/migrators/namespace_dart_test.dart create mode 100644 test/migrators/namespace_node_test.dart create mode 100644 test/renamer_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b6afd285..c06e36c4 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 6bd73c7f..5b554c9d 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 faa964e5..88bb7d16 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 6f1d27a2..55ee9618 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 00000000..41214947 --- /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 00000000..e2179ae3 --- /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 a3746401..f3568433 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 a53c02c5..5396f3b2 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 00000000..a1197616 --- /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 00000000..1221686c --- /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 00000000..11e8ac40 --- /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 00000000..490b3b57 --- /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 00000000..32830e6c --- /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 00000000..846a173f --- /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 00000000..e755e97b --- /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 00000000..350cdf6e --- /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 00000000..9d77f504 --- /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 00000000..cf25c1f9 --- /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 f6e745b5..774eb9d3 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) {