Skip to content

Commit 2d959de

Browse files
authored
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`.
1 parent 7285d15 commit 2d959de

19 files changed

+868
-14
lines changed

CHANGELOG.md

+33
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,36 @@
1+
## 1.3.0
2+
3+
### Namespace Migrator
4+
5+
* Add a new migrator for changing namespaces of `@use` rules.
6+
7+
This migrator lets you change namespaces by matching regular expressions on
8+
existing namespaces or on `@use` rule URLs.
9+
10+
You do this by passing expressions to the `--rename` in one of the following
11+
forms:
12+
13+
* `<old-namespace> to <new-namespace>`: The `<old-namespace>` regular
14+
expression matches the entire existing namespace, and `<new-namespace>` is
15+
the replacement.
16+
17+
* `url <rule-url> to <new-namespace>`: The `<old-namespace>` regular
18+
expression matches the entire URL in the `@use` rule, and `<new-namespace>`
19+
is the namespace that's chosen for it.
20+
21+
The `<new-namespace>` patterns can include references to [captured groups][]
22+
from the matching regular expression (e.g. `\1`).
23+
24+
[captured groups]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Groups_and_Ranges
25+
26+
You can pass `--rename` multiple times and they will be checked in order until
27+
one matches (at which point subsequent renames will be ignored). You can also
28+
separate multiple rename expressions with semicolons or line breaks.
29+
30+
By default, if the renaming results in a conflict between multiple `@use`
31+
rules, the migration will fail, but you can force it to resolve conflicts with
32+
numerical suffixes by passing `--force`.
33+
134
## 1.2.6
235

336
### Module Migrator

lib/src/migration_visitor.dart

+1-1
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ abstract class MigrationVisitor extends RecursiveAstVisitor {
6666
Importer get importer => _importer;
6767
Importer _importer;
6868

69-
MigrationVisitor(this.importCache, {this.migrateDependencies = true});
69+
MigrationVisitor(this.importCache, this.migrateDependencies);
7070

7171
/// Runs a new migration on [stylesheet] (and its dependencies, if
7272
/// [migrateDependencies] is true) and returns a map of migrated contents.

lib/src/migrators/division.dart

+1-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ class _DivisionMigrationVisitor extends MigrationVisitor {
5656

5757
_DivisionMigrationVisitor(
5858
ImportCache importCache, this.isPessimistic, bool migrateDependencies)
59-
: super(importCache, migrateDependencies: migrateDependencies);
59+
: super(importCache, migrateDependencies);
6060

6161
/// True when division is allowed by the context the current node is in.
6262
var _isDivisionAllowed = false;

lib/src/migrators/module.dart

+6-9
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,8 @@ class ModuleMigrator extends Migrator {
9393
}
9494

9595
var references = References(importCache, stylesheet, importer);
96-
var visitor = _ModuleMigrationVisitor(
97-
importCache, references, globalResults['load-path'] as List<String>,
98-
migrateDependencies: migrateDependencies,
96+
var visitor = _ModuleMigrationVisitor(importCache, references,
97+
globalResults['load-path'] as List<String>, migrateDependencies,
9998
prefixesToRemove: (argResults['remove-prefix'] as List<String>)
10099
?.map((prefix) => prefix.replaceAll('_', '-')),
101100
forwards: forwards);
@@ -204,17 +203,15 @@ class _ModuleMigrationVisitor extends MigrationVisitor {
204203
/// the module migrator will filter out the dependencies' migration results.
205204
///
206205
/// This converts the OS-specific relative [loadPaths] to absolute URL paths.
207-
_ModuleMigrationVisitor(
208-
this.importCache, this.references, List<String> loadPaths,
209-
{bool migrateDependencies,
210-
Iterable<String> prefixesToRemove,
211-
this.forwards})
206+
_ModuleMigrationVisitor(this.importCache, this.references,
207+
List<String> loadPaths, bool migrateDependencies,
208+
{Iterable<String> prefixesToRemove, this.forwards})
212209
: loadPaths = List.unmodifiable(
213210
loadPaths.map((path) => p.toUri(p.absolute(path)).path)),
214211
prefixesToRemove = prefixesToRemove == null
215212
? const {}
216213
: UnmodifiableSetView(prefixesToRemove.toSet()),
217-
super(importCache, migrateDependencies: migrateDependencies);
214+
super(importCache, migrateDependencies);
218215

219216
/// Checks which global declarations need to be renamed, then runs the
220217
/// migrator.

lib/src/migrators/namespace.dart

+207
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
// Copyright 2021 Google LLC
2+
//
3+
// Use of this source code is governed by an MIT-style
4+
// license that can be found in the LICENSE file or at
5+
// https://opensource.org/licenses/MIT.
6+
7+
import 'package:args/args.dart';
8+
import 'package:sass/sass.dart';
9+
import 'package:source_span/source_span.dart';
10+
11+
// The sass package's API is not necessarily stable. It is being imported with
12+
// the Sass team's explicit knowledge and approval. See
13+
// https://github.com/sass/dart-sass/issues/236.
14+
import 'package:sass/src/ast/sass.dart';
15+
import 'package:sass/src/exception.dart';
16+
import 'package:sass/src/import_cache.dart';
17+
18+
import '../migration_visitor.dart';
19+
import '../migrator.dart';
20+
import '../patch.dart';
21+
import '../utils.dart';
22+
import '../renamer.dart';
23+
24+
/// Changes namespaces for `@use` rules within the file(s) being migrated.
25+
class NamespaceMigrator extends Migrator {
26+
final name = "namespace";
27+
final description = "Change namespaces for `@use` rules.";
28+
29+
@override
30+
final argParser = ArgParser()
31+
..addMultiOption('rename',
32+
abbr: 'r',
33+
splitCommas: false,
34+
help: 'e.g. "old-namespace to new-namespace" or\n'
35+
' "url my/url to new-namespace"\n'
36+
'See https://sass-lang.com/documentation/cli/migrator#rename.')
37+
..addFlag('force',
38+
abbr: 'f',
39+
help: 'Force rename namespaces, adding numerical suffixes for '
40+
'conflicts.');
41+
42+
@override
43+
Map<Uri, String> migrateFile(
44+
ImportCache importCache, Stylesheet stylesheet, Importer importer) {
45+
var renamer = Renamer<UseRule>(argResults['rename'].join('\n'),
46+
{'': (rule) => rule.namespace, 'url': (rule) => rule.url.toString()},
47+
sourceUrl: '--rename');
48+
var visitor = _NamespaceMigrationVisitor(
49+
renamer, argResults['force'] as bool, importCache, migrateDependencies);
50+
var result = visitor.run(stylesheet, importer);
51+
missingDependencies.addAll(visitor.missingDependencies);
52+
return result;
53+
}
54+
}
55+
56+
class _NamespaceMigrationVisitor extends MigrationVisitor {
57+
final Renamer<UseRule> renamer;
58+
final bool forceRename;
59+
60+
/// A set of spans for each *original* namespace in the current file.
61+
///
62+
/// Each span covers just the namespace of a member reference.
63+
Map<String, Set<FileSpan>> _spansByNamespace;
64+
65+
/// The set of namespaces used in the current file *after* renaming.
66+
Set<String> _usedNamespaces;
67+
68+
_NamespaceMigrationVisitor(this.renamer, this.forceRename,
69+
ImportCache importCache, bool migrateDependencies)
70+
: super(importCache, migrateDependencies);
71+
72+
@override
73+
void visitStylesheet(Stylesheet node) {
74+
var oldSpansByNamespace = _spansByNamespace;
75+
var oldUsedNamespaces = _usedNamespaces;
76+
_spansByNamespace = {};
77+
_usedNamespaces = {};
78+
super.visitStylesheet(node);
79+
_spansByNamespace = oldSpansByNamespace;
80+
_usedNamespaces = oldUsedNamespaces;
81+
}
82+
83+
@override
84+
void beforePatch(Stylesheet node) {
85+
// Pass each `@use` rule through the renamer.
86+
var newNamespaces = <String, Set<UseRule>>{};
87+
for (var rule in node.children.whereType<UseRule>()) {
88+
if (rule.namespace == null) continue;
89+
newNamespaces
90+
.putIfAbsent(renamer.rename(rule) ?? rule.namespace, () => {})
91+
.add(rule);
92+
}
93+
94+
// Goes through each new namespace, resolving conflicts if necessary.
95+
for (var entry in newNamespaces.entries) {
96+
var newNamespace = entry.key;
97+
var rules = entry.value;
98+
if (rules.length == 1) {
99+
_patchNamespace(rules.first, newNamespace);
100+
continue;
101+
}
102+
103+
// If there's still a conflict, fail unless --force is passed.
104+
if (!forceRename) {
105+
throw MultiSpanSassException(
106+
'Rename failed. ${rules.length} rules would use namespace '
107+
'"$newNamespace".\n'
108+
'Run with --force to rename with numerical suffixes.',
109+
rules.first.span,
110+
'',
111+
{for (var rule in rules.skip(1)) rule.span: ''});
112+
}
113+
114+
// With --force, give the first rule its preferred namespace and then
115+
// add numerical suffixes to the rest.
116+
var suffix = 2;
117+
for (var rule in rules) {
118+
var forcedNamespace = newNamespace;
119+
while (_usedNamespaces.contains(forcedNamespace)) {
120+
forcedNamespace = '$newNamespace$suffix';
121+
suffix++;
122+
}
123+
_patchNamespace(rule, forcedNamespace);
124+
}
125+
}
126+
}
127+
128+
/// Patch [rule] and all references to it with [newNamespace].
129+
void _patchNamespace(UseRule rule, String newNamespace) {
130+
_usedNamespaces.add(newNamespace);
131+
if (rule.namespace == newNamespace) return;
132+
var asClause =
133+
RegExp('\\s*as\\s+(${rule.namespace})').firstMatch(rule.span.text);
134+
if (asClause == null) {
135+
// Add an `as` clause to a rule that previously lacked one.
136+
var end = RegExp(r"""@use\s("|').*?\1""").firstMatch(rule.span.text).end;
137+
addPatch(
138+
Patch.insert(rule.span.subspan(0, end).end, ' as $newNamespace'));
139+
} else if (namespaceForPath(rule.url.toString()) == newNamespace) {
140+
// Remove an `as` clause that is no longer necessary.
141+
addPatch(
142+
patchDelete(rule.span, start: asClause.start, end: asClause.end));
143+
} else {
144+
// Change the namespace of an existing `as` clause.
145+
addPatch(Patch(
146+
rule.span.subspan(asClause.end - rule.namespace.length, asClause.end),
147+
newNamespace));
148+
}
149+
for (FileSpan span in _spansByNamespace[rule.namespace] ?? {}) {
150+
addPatch(Patch(span, newNamespace));
151+
}
152+
}
153+
154+
/// If [namespace] is not null, add its span to [_spansByNamespace].
155+
void _addNamespaceSpan(String namespace, FileSpan span) {
156+
if (namespace != null) {
157+
assert(span.text.startsWith(namespace));
158+
_spansByNamespace
159+
.putIfAbsent(namespace, () => {})
160+
.add(subspan(span, end: namespace.length));
161+
}
162+
}
163+
164+
@override
165+
void visitFunctionExpression(FunctionExpression node) {
166+
_addNamespaceSpan(node.namespace, node.span);
167+
var name = node.name.asPlain;
168+
if (name == 'get-function') {
169+
var moduleArg = node.arguments.named['module'];
170+
if (node.arguments.positional.length == 3) {
171+
moduleArg ??= node.arguments.positional[2];
172+
}
173+
if (moduleArg is StringExpression) {
174+
var namespace = moduleArg.text.asPlain;
175+
if (namespace != null) {
176+
var span = moduleArg.hasQuotes
177+
? moduleArg.span.subspan(1, moduleArg.span.length - 1)
178+
: moduleArg.span;
179+
_addNamespaceSpan(namespace, span);
180+
}
181+
}
182+
}
183+
super.visitFunctionExpression(node);
184+
}
185+
186+
@override
187+
void visitIncludeRule(IncludeRule node) {
188+
if (node.namespace != null) {
189+
var startNamespace = node.span.text.indexOf(
190+
node.namespace, node.span.text[0] == '+' ? 1 : '@include'.length);
191+
_addNamespaceSpan(node.namespace, node.span.subspan(startNamespace));
192+
}
193+
super.visitIncludeRule(node);
194+
}
195+
196+
@override
197+
void visitVariableDeclaration(VariableDeclaration node) {
198+
_addNamespaceSpan(node.namespace, node.span);
199+
super.visitVariableDeclaration(node);
200+
}
201+
202+
@override
203+
void visitVariableExpression(VariableExpression node) {
204+
_addNamespaceSpan(node.namespace, node.span);
205+
super.visitVariableExpression(node);
206+
}
207+
}

0 commit comments

Comments
 (0)