Skip to content

Commit

Permalink
Adds the namespace migrator (#173)
Browse files Browse the repository at this point in the history
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`.
  • Loading branch information
jathak authored Feb 8, 2021
1 parent 7285d15 commit 2d959de
Show file tree
Hide file tree
Showing 19 changed files with 868 additions and 14 deletions.
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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:

* `<old-namespace> to <new-namespace>`: The `<old-namespace>` regular
expression matches the entire existing namespace, and `<new-namespace>` is
the replacement.

* `url <rule-url> to <new-namespace>`: The `<old-namespace>` regular
expression matches the entire URL in the `@use` rule, and `<new-namespace>`
is the namespace that's chosen for it.

The `<new-namespace>` 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
Expand Down
2 changes: 1 addition & 1 deletion lib/src/migration_visitor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion lib/src/migrators/division.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 6 additions & 9 deletions lib/src/migrators/module.dart
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,8 @@ class ModuleMigrator extends Migrator {
}

var references = References(importCache, stylesheet, importer);
var visitor = _ModuleMigrationVisitor(
importCache, references, globalResults['load-path'] as List<String>,
migrateDependencies: migrateDependencies,
var visitor = _ModuleMigrationVisitor(importCache, references,
globalResults['load-path'] as List<String>, migrateDependencies,
prefixesToRemove: (argResults['remove-prefix'] as List<String>)
?.map((prefix) => prefix.replaceAll('_', '-')),
forwards: forwards);
Expand Down Expand Up @@ -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<String> loadPaths,
{bool migrateDependencies,
Iterable<String> prefixesToRemove,
this.forwards})
_ModuleMigrationVisitor(this.importCache, this.references,
List<String> loadPaths, bool migrateDependencies,
{Iterable<String> 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.
Expand Down
207 changes: 207 additions & 0 deletions lib/src/migrators/namespace.dart
Original file line number Diff line number Diff line change
@@ -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<Uri, String> migrateFile(
ImportCache importCache, Stylesheet stylesheet, Importer importer) {
var renamer = Renamer<UseRule>(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<UseRule> 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<String, Set<FileSpan>> _spansByNamespace;

/// The set of namespaces used in the current file *after* renaming.
Set<String> _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 = <String, Set<UseRule>>{};
for (var rule in node.children.whereType<UseRule>()) {
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);
}
}
Loading

0 comments on commit 2d959de

Please sign in to comment.