diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f5c3bbe..0059d725 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,7 @@ All submissions, including submissions by project members, require review. ### File headers All files in the project must start with the following header. - // Copyright 2018 Google LLC + // Copyright 2019 Google LLC // // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file or at diff --git a/bin/sass_migrator.dart b/bin/sass_migrator.dart new file mode 100644 index 00000000..9e32cde0 --- /dev/null +++ b/bin/sass_migrator.dart @@ -0,0 +1,9 @@ +// Copyright 2019 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/runner.dart'; + +main(List args) => MigratorRunner().execute(args); diff --git a/bin/sass_module_migrator.dart b/bin/sass_module_migrator.dart deleted file mode 100644 index 6ebddea0..00000000 --- a/bin/sass_module_migrator.dart +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright 2018 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 'dart:io'; - -import 'package:args/args.dart'; - -import 'package:sass_module_migrator/src/migrator.dart'; - -void main(List args) { - var argParser = new ArgParser() - ..addFlag('dry-run', - abbr: 'n', - help: 'Show which files would be migrated but make no changes.') - ..addFlag('verbose', - abbr: 'v', - help: 'Print text of migrated files when running with --dry-run.') - ..addFlag('help', abbr: 'h', help: 'Print help text.', negatable: false) - ..addFlag('recursive', - abbr: 'r', - help: - 'Migrate all dependencies in addition to the entrypoints themselves.'); - var argResults = argParser.parse(args); - - if (argResults['help'] == true || argResults.rest.isEmpty) { - print('Migrates one or more Sass files to the new module system.\n\n' - 'Usage: sass_migrate_to_modules [options] \n\n' - '${argParser.usage}'); - exitCode = 64; - return; - } - - var migrated = migrateFiles(argResults.rest); - - if (migrated.isEmpty) { - print('Nothing to migrate!'); - return; - } - - if (argResults['dry-run']) { - print('Dry run. Logging migrated files instead of overwriting...\n'); - for (var path in migrated.keys) { - print('$path'); - if (argResults['verbose']) { - print('=' * 80); - print(migrated[path]); - } - } - } else { - for (var path in migrated.keys) { - if (argResults['verbose']) print("Overwriting $path..."); - File(path).writeAsStringSync(migrated[path]); - } - } -} diff --git a/lib/runner.dart b/lib/runner.dart new file mode 100644 index 00000000..6a1510d6 --- /dev/null +++ b/lib/runner.dart @@ -0,0 +1,63 @@ +// Copyright 2019 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 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:path/path.dart' as p; + +import 'src/migrators/module.dart'; + +/// A command runner that runs a migrator based on provided arguments. +class MigratorRunner extends CommandRunner> { + final invocation = "sass_migrator [options] "; + + MigratorRunner() + : super("sass_migrator", "Migrates stylesheets to new Sass versions.") { + argParser.addFlag('migrate-deps', + abbr: 'd', help: 'Migrate dependencies in addition to entrypoints.'); + argParser.addFlag('dry-run', + abbr: 'n', + help: 'Show which files would be migrated but make no changes.'); + // TODO(jathak): Make this flag print a diff instead. + argParser.addFlag('verbose', + abbr: 'v', + help: 'Print text of migrated files when running with --dry-run.'); + addCommand(ModuleMigrator()); + } + + /// Runs a migrator and then writes the migrated files to disk unless + /// `--dry-run` is passed. + Future execute(Iterable args) async { + var argResults = parse(args); + var migrated = await runCommand(argResults); + if (migrated == null) return; + + if (migrated.isEmpty) { + print('Nothing to migrate!'); + return; + } + + if (argResults['dry-run']) { + print('Dry run. Logging migrated files instead of overwriting...\n'); + for (var url in migrated.keys) { + print(p.prettyUri(url)); + if (argResults['verbose']) { + print('=' * 80); + print(migrated[url]); + print('-' * 80); + } + } + } else { + for (var url in migrated.keys) { + assert(url.scheme == null || url.scheme == "file", + "$url is not a file path."); + if (argResults['verbose']) print("Overwriting $url..."); + File(url.toFilePath()).writeAsStringSync(migrated[url]); + } + } + } +} diff --git a/lib/src/migration_visitor.dart b/lib/src/migration_visitor.dart new file mode 100644 index 00000000..195d2a1d --- /dev/null +++ b/lib/src/migration_visitor.dart @@ -0,0 +1,114 @@ +// Copyright 2019 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 'dart:collection'; + +// 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/visitor/recursive_ast.dart'; + +import 'package:meta/meta.dart'; + +import 'patch.dart'; +import 'utils.dart'; + +/// A visitor that migrates a stylesheet. +/// +/// When [run] is called, this visitor traverses a stylesheet's AST, allowing +/// subclasses to override one or more methods and add to [patches]. Once the +/// stylesheet has been visited, the migrated contents (based on [patches]) will +/// be stored in [migrator]'s [migrated] map. +/// +/// If [migrateDependencies] is enabled, this visitor will construct and run a +/// new instance of itself (using [newInstance]) each time it encounters an +/// `@import` or `@use` rule. +abstract class MigrationVisitor extends RecursiveAstVisitor { + /// A mapping from URLs to migrated contents for stylesheets already migrated. + final _migrated = {}; + + /// True if dependencies should be migrated as well. + final bool migrateDependencies; + + /// The patches to be applied to the stylesheet being migrated. + UnmodifiableListView get patches => UnmodifiableListView(_patches); + List _patches; + + MigrationVisitor({this.migrateDependencies = true}); + + /// Runs a new migration on [url] (and its dependencies, if + /// [migrateDependencies] is true) and returns a map of migrated contents. + Map run(Uri url) { + visitStylesheet(parseStylesheet(url)); + return _migrated; + } + + /// Visits stylesheet starting with an empty [_patches], adds the migrated + /// contents (if any) to [_migrated], and then restores the previous value of + /// [_patches]. + /// + /// Migrators with per-file state should override this to store the current + /// file's state before calling the super method and restore it afterwards. + @override + void visitStylesheet(Stylesheet node) { + var oldPatches = _patches; + _patches = []; + super.visitStylesheet(node); + var results = getMigratedContents(); + if (results != null) { + _migrated[node.span.sourceUrl] = results; + } + _patches = oldPatches; + } + + /// Visits the stylesheet at [dependency], resolved relative to [source]. + @protected + void visitDependency(Uri dependency, Uri source) { + var stylesheet = parseStylesheet(source.resolveUri(dependency)); + visitStylesheet(stylesheet); + } + + /// Returns the migrated contents of this file, or null if the file does not + /// change. + /// + /// This will be called by [run] and the results will be stored in + /// `migrator.migrated`. + @protected + String getMigratedContents() => patches.isNotEmpty + ? Patch.applyAll(patches.first.selection.file, patches) + : null; + + /// Adds a new patch that should be applied to the current stylesheet. + @protected + void addPatch(Patch patch) { + _patches.add(patch); + } + + /// If [migrateDependencies] is enabled, any dynamic imports within + /// this [node] will be migrated before continuing. + @override + visitImportRule(ImportRule node) { + super.visitImportRule(node); + if (migrateDependencies) { + for (var import in node.imports) { + if (import is DynamicImport) { + visitDependency(Uri.parse(import.url), node.span.sourceUrl); + } + } + } + } + + /// If [migrateDependencies] is enabled, this dependency will be + /// migrated before continuing. + @override + visitUseRule(UseRule node) { + super.visitUseRule(node); + if (migrateDependencies) { + visitDependency(node.url, node.span.sourceUrl); + } + } +} diff --git a/lib/src/migrator.dart b/lib/src/migrator.dart index 18fb9ad2..34ee257c 100644 --- a/lib/src/migrator.dart +++ b/lib/src/migrator.dart @@ -1,362 +1,60 @@ -// Copyright 2018 Google LLC +// Copyright 2019 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. -// 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/visitor/recursive_statement.dart'; -import 'package:sass/src/visitor/interface/expression.dart'; +import 'package:args/command_runner.dart'; +import 'package:meta/meta.dart'; -import 'package:source_span/source_span.dart'; -import 'package:path/path.dart' as p; - -import 'built_in_functions.dart'; -import 'local_scope.dart'; -import 'patch.dart'; -import 'stylesheet_migration.dart'; import 'utils.dart'; -/// Runs a migration of multiple [entrypoints] and their dependencies without -/// writing any changes to disk. +/// A migrator is a command that migrates the entrypoints provided to it and +/// (optionally) their dependencies. /// -/// Each entrypoint migrates all of its dependencies separately from the other -/// entrypoints. Certain stylesheets may be migrated multiple times. If the -/// migrated text of a stylesheet for each run is not identical, this will -/// error. +/// Migrators should provide their [name], [description], and optionally +/// [aliases]. /// -/// If [directory] is provided, the entrypoints will be interpreted relative to -/// it. Otherwise, they'll be interpreted relative to the current directory. +/// Subclasses need to implement [migrateFile], which takes an entrypoint, +/// migrates it, and stores the results in [migrated]. If [migrateDependencies] +/// is true, they should also migrate all of that entrypoint's direct and +/// indirect dependencies. /// -/// Entrypoints and dependencies that did not require any changes will not be -/// included in the results. -p.PathMap migrateFiles(Iterable entrypoints, - {String directory}) { - var allMigrated = p.PathMap(); - for (var entrypoint in entrypoints) { - var migrated = _Migrator(directory: directory).migrate(entrypoint); - for (var file in migrated.keys) { - if (allMigrated.containsKey(file) && - migrated[file] != allMigrated[file]) { - throw UnsupportedError( - "$file is migrated in more than one way by these entrypoints."); - } - allMigrated[file] = migrated[file]; - } - } - return allMigrated; -} - -class _Migrator extends RecursiveStatementVisitor implements ExpressionVisitor { - /// List of all migrations for files touched by this run. - final _migrations = p.PathMap(); - - /// List of migrations in progress. The last item is the current migration. - final _activeMigrations = []; - - /// Global variables defined at any time during the migrator run. - final _variables = normalizedMap(); - - /// Global mixins defined at any time during the migrator run. - final _mixins = normalizedMap(); - - /// Global functions defined at any time during the migrator run. - final _functions = normalizedMap(); - - /// Directory this migration is run from. - final String _directory; - - _Migrator({String directory}) : _directory = directory ?? p.current; - - /// Local variables, mixins, and functions for migrations in progress. +/// Most migrators will want to create a subclass of [MigrationVisitor] and +/// implement [migrateFile] with `MyMigrationVisitor(this, entrypoint).run()`. +abstract class Migrator extends Command> { + /// If true, dependencies will be migrated in addition to the entrypoints. + bool get migrateDependencies => globalResults['migrate-deps'] as bool; + + /// Runs this migrator on [entrypoint] (and its dependencies, if the + /// --migrate-deps flag is passed). /// - /// The migrator will modify this as it traverses stylesheets. When at the - /// top level of a stylesheet, this will be null. - LocalScope _localScope; - - /// Current stylesheet being actively migrated. - StylesheetMigration get _currentMigration => - _activeMigrations.isNotEmpty ? _activeMigrations.last : null; - - /// Runs the migrator on [entrypoint] and its dependencies and returns a map - /// of migrated contents. - p.PathMap migrate(String entrypoint) { - _migrateStylesheet(entrypoint); - var results = p.PathMap(); - for (var migration in _migrations.values) { - results[migration.path] = migration.migratedContents; - } - return results; - } + /// Files that did not require any changes, even if touched by the migrator, + /// should not be included map of results. + @protected + Map migrateFile(Uri entrypoint); - /// Migrates the stylesheet at [path] if it hasn't already been migrated and - /// returns the StylesheetMigration instance for it regardless. - StylesheetMigration _migrateStylesheet(String path) { - path = canonicalizePath(p.join( - _currentMigration == null - ? _directory - : p.dirname(_currentMigration.path), - path)); - return _migrations.putIfAbsent(path, () { - var migration = StylesheetMigration(path); - _activeMigrations.add(migration); - visitStylesheet(migration.stylesheet); - _activeMigrations.remove(migration); - return migration; - }); - } - - /// Visits the children of [node] with a local scope. + /// Runs this migrator. /// - /// Note: The children of a stylesheet are at the root, so we should not add - /// a local scope. - @override - void visitChildren(ParentStatement node) { - if (node is Stylesheet) { - super.visitChildren(node); - return; - } - _localScope = LocalScope(_localScope); - super.visitChildren(node); - _localScope = _localScope.parent; - } - - /// Adds a namespace to any function call that require it. - void visitFunctionExpression(FunctionExpression node) { - visitInterpolation(node.name); - _patchNamespaceForFunction(node.name.asPlain, (name, namespace) { - _currentMigration.patches.add(Patch(node.name.span, "$namespace.$name")); - }); - visitArgumentInvocation(node.arguments); - - if (node.name.asPlain == "get-function") { - var nameArgument = - node.arguments.named['name'] ?? node.arguments.positional.first; - if (nameArgument is! StringExpression || - (nameArgument as StringExpression).text.asPlain == null) { - print(nameArgument.span.message( - "WARNING - get-function call may require \$module parameter")); - return; - } - var fnName = nameArgument as StringExpression; - _patchNamespaceForFunction(fnName.text.asPlain, (name, namespace) { - var span = fnName.span; - if (fnName.hasQuotes) { - span = span.file.span(span.start.offset + 1, span.end.offset - 1); - } - _currentMigration.patches.add(Patch(span, name)); - var beforeParen = node.span.end.offset - 1; - _currentMigration.patches.add(Patch( - node.span.file.span(beforeParen, beforeParen), - ', \$module: "$namespace"')); - }); - } - } - - /// Calls [patcher] when the function [name] requires a namespace and adds a - /// new use rule if necessary. + /// Each entrypoint is migrated separately. If a stylesheet is migrated more + /// than once, the resulting migrated contents must be the same each time, or + /// this will error. /// - /// [patcher] takes two arguments: the name used to refer to that function - /// when namespaced, and the namespace itself. The name will match the name - /// provided to the outer function except for built-in functions whose name - /// within a module differs from its original name. - void _patchNamespaceForFunction( - String name, void patcher(String name, String namespace)) { - if (name == null) return; - if (_localScope?.isLocalFunction(name) ?? false) return; - - var namespace = _functions.containsKey(name) - ? _currentMigration.namespaceForNode(_functions[name]) - : null; - - if (namespace == null) { - if (!builtInFunctionModules.containsKey(name)) return; - - namespace = builtInFunctionModules[name]; - name = builtInFunctionNameChanges[name] ?? name; - _currentMigration.additionalUseRules.add("sass:$namespace"); - } - if (namespace != null) patcher(name, namespace); - } - - /// Declares the function within the current scope before visiting it. - @override - void visitFunctionRule(FunctionRule node) { - _declareFunction(node); - super.visitFunctionRule(node); - } - - /// Migrates @import to @use after migrating the imported file. - void visitImportRule(ImportRule node) { - if (node.imports.first is StaticImport) { - super.visitImportRule(node); - return; - } - if (node.imports.length > 1) { - throw UnimplementedError( - "Migration of @import rule with multiple imports not supported."); - } - var import = node.imports.first as DynamicImport; - - if (_localScope != null) { - // TODO(jathak): Handle nested imports - return; - } - // TODO(jathak): Confirm that this import appears before other rules - - var importMigration = _migrateStylesheet(import.url); - _currentMigration.namespaces[importMigration.path] = - namespaceForPath(import.url); - - var overrides = []; - for (var variable in importMigration.configurableVariables) { - if (_variables.containsKey(variable)) { - var declaration = _variables[variable]; - if (_currentMigration.namespaceForNode(declaration) == null) { - overrides.add("\$${declaration.name}: ${declaration.expression}"); + /// Entrypoints and dependencies that did not require any changes will not be + /// included in the results. + Map run() { + var allMigrated = Map(); + for (var entrypoint in argResults.rest) { + var migrated = migrateFile(canonicalize(Uri.parse(entrypoint))); + for (var file in migrated.keys) { + if (allMigrated.containsKey(file) && + migrated[file] != allMigrated[file]) { + throw MigrationException( + "$file is migrated in more than one way by these entrypoints."); } - // TODO(jathak): Remove this declaration from the current stylesheet if - // it's not referenced before this point. + allMigrated[file] = migrated[file]; } } - var config = ""; - if (overrides.isNotEmpty) { - config = " with (\n " + overrides.join(',\n ') + "\n)"; - } - _currentMigration.patches - .add(Patch(node.span, '@use ${import.span.text}$config')); + return allMigrated; } - - /// Adds a namespace to any mixin include that requires it. - @override - void visitIncludeRule(IncludeRule node) { - super.visitIncludeRule(node); - if (_localScope?.isLocalMixin(node.name) ?? false) return; - if (!_mixins.containsKey(node.name)) return; - var namespace = _currentMigration.namespaceForNode(_mixins[node.name]); - if (namespace == null) return; - var endName = node.arguments.span.start.offset; - var startName = endName - node.name.length; - var nameSpan = node.span.file.span(startName, endName); - _currentMigration.patches.add(Patch(nameSpan, "$namespace.${node.name}")); - } - - /// Declares the mixin within the current scope before visiting it. - @override - void visitMixinRule(MixinRule node) { - _declareMixin(node); - super.visitMixinRule(node); - } - - @override - void visitUseRule(UseRule node) { - // TODO(jathak): Handle existing @use rules. - } - - /// Adds a namespace to any variable that requires it. - visitVariableExpression(VariableExpression node) { - if (_localScope?.isLocalVariable(node.name) ?? false) { - return; - } - if (!_variables.containsKey(node.name)) return; - var namespace = _currentMigration.namespaceForNode(_variables[node.name]); - if (namespace == null) return; - _currentMigration.patches - .add(Patch(node.span, "\$$namespace.${node.name}")); - } - - /// Declares a variable within the current scope before visiting it. - @override - void visitVariableDeclaration(VariableDeclaration node) { - _declareVariable(node); - super.visitVariableDeclaration(node); - } - - /// Declares a variable within this stylesheet, in the current local scope if - /// it exists, or as a global variable otherwise. - void _declareVariable(VariableDeclaration node) { - if (_localScope == null || node.isGlobal) { - if (node.isGuarded) { - _currentMigration.configurableVariables.add(node.name); - - // Don't override if variable already exists. - _variables.putIfAbsent(node.name, () => node); - } else { - _variables[node.name] = node; - } - } else { - _localScope.variables.add(node.name); - } - } - - /// Declares a mixin within this stylesheet, in the current local scope if - /// it exists, or as a global mixin otherwise. - void _declareMixin(MixinRule node) { - if (_localScope == null) { - _mixins[node.name] = node; - } else { - _localScope.mixins.add(node.name); - } - } - - /// Declares a function within this stylesheet, in the current local scope if - /// it exists, or as a global function otherwise. - void _declareFunction(FunctionRule node) { - if (_localScope == null) { - _functions[node.name] = node; - } else { - _localScope.functions.add(node.name); - } - } - - // Expression Tree Treversal - - @override - visitExpression(Expression expression) => expression.accept(this); - - visitBinaryOperationExpression(BinaryOperationExpression node) { - node.left.accept(this); - node.right.accept(this); - } - - visitIfExpression(IfExpression node) { - visitArgumentInvocation(node.arguments); - } - - visitListExpression(ListExpression node) { - for (var item in node.contents) { - item.accept(this); - } - } - - visitMapExpression(MapExpression node) { - for (var pair in node.pairs) { - pair.item1.accept(this); - pair.item2.accept(this); - } - } - - visitParenthesizedExpression(ParenthesizedExpression node) { - node.expression.accept(this); - } - - visitStringExpression(StringExpression node) { - visitInterpolation(node.text); - } - - visitUnaryOperationExpression(UnaryOperationExpression node) { - node.operand.accept(this); - } - - // No-Op Expression Tree Leaves - - visitBooleanExpression(BooleanExpression node) {} - visitColorExpression(ColorExpression node) {} - visitNullExpression(NullExpression node) {} - visitNumberExpression(NumberExpression node) {} - visitSelectorExpression(SelectorExpression node) {} - visitValueExpression(ValueExpression node) {} } diff --git a/lib/src/migrators/module.dart b/lib/src/migrators/module.dart new file mode 100644 index 00000000..386f5e35 --- /dev/null +++ b/lib/src/migrators/module.dart @@ -0,0 +1,306 @@ +// Copyright 2019 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. + +// 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:path/path.dart' as p; + +import 'package:sass_migrator/src/migration_visitor.dart'; +import 'package:sass_migrator/src/migrator.dart'; +import 'package:sass_migrator/src/patch.dart'; +import 'package:sass_migrator/src/utils.dart'; + +import 'module/built_in_functions.dart'; +import 'module/local_scope.dart'; + +/// Migrates stylesheets to the new module system. +class ModuleMigrator extends Migrator { + final name = "module"; + final description = "Migrates stylesheets to the new module system."; + + /// Runs the module migrator on [entrypoint] and its dependencies and returns + /// a map of migrated contents. + /// + /// If [migrateDependencies] is false, the migrator will still be run on + /// dependencies, but they will be excluded from the resulting map. + Map migrateFile(Uri entrypoint) { + var migrated = _ModuleMigrationVisitor().run(entrypoint); + if (!migrateDependencies) { + migrated.removeWhere((url, contents) => url != entrypoint); + } + return migrated; + } +} + +class _ModuleMigrationVisitor extends MigrationVisitor { + /// Global variables defined at any time during the migrator run. + final _globalVariables = normalizedMap(); + + /// Global mixins defined at any time during the migrator run. + final _globalMixins = normalizedMap(); + + /// Global functions defined at any time during the migrator run. + final _globalFunctions = normalizedMap(); + + /// Namespaces of modules used in this stylesheet. + Map _namespaces; + + /// Set of additional use rules necessary for referencing members of + /// implicit dependencies / built-in modules. + /// + /// This set contains the path provided in the use rule, not the canonical + /// path (e.g. "a" rather than "dir/a.scss"). + Set _additionalUseRules; + + /// The URL of the current stylesheet. + Uri _currentUrl; + + /// The URL of the last stylesheet that was completely migrated. + Uri _lastUrl; + + /// Local variables, mixins, and functions for this migration. + /// + /// When at the top level of the stylesheet, this will be null. + LocalScope _localScope; + + /// Constructs a new module migration visitor. + /// + /// Note: We always set [migratedDependencies] to true since the module + /// migrator needs to always run on dependencies. The `migrateFile` method of + /// the module migrator will filter out the dependencies' migration results. + _ModuleMigrationVisitor() : super(migrateDependencies: true); + + /// Returns the migrated contents of this stylesheet, based on [patches] and + /// [_additionalUseRules], or null if the stylesheet does not change. + @override + String getMigratedContents() { + var results = super.getMigratedContents(); + if (results == null) return null; + var semicolon = _currentUrl.path.endsWith('.sass') ? "" : ";"; + var uses = _additionalUseRules.map((use) => '@use "$use"$semicolon\n'); + return uses.join() + results; + } + + /// Stores per-file state before visiting [node] and restores it afterwards. + @override + void visitStylesheet(Stylesheet node) { + var oldNamespaces = _namespaces; + var oldAdditionalUseRules = _additionalUseRules; + var oldUrl = _currentUrl; + _namespaces = {}; + _additionalUseRules = Set(); + _currentUrl = node.span.sourceUrl; + super.visitStylesheet(node); + _namespaces = oldNamespaces; + _additionalUseRules = oldAdditionalUseRules; + _lastUrl = _currentUrl; + _currentUrl = oldUrl; + } + + /// Visits the children of [node] with a local scope. + /// + /// Note: The children of a stylesheet are at the root, so we should not add + /// a local scope. + @override + void visitChildren(ParentStatement node) { + if (node is Stylesheet) { + super.visitChildren(node); + return; + } + _localScope = LocalScope(_localScope); + super.visitChildren(node); + _localScope = _localScope.parent; + } + + /// Adds a namespace to any function call that requires it. + @override + void visitFunctionExpression(FunctionExpression node) { + visitInterpolation(node.name); + _patchNamespaceForFunction(node.name.asPlain, (name, namespace) { + addPatch(Patch(node.name.span, "$namespace.$name")); + }); + visitArgumentInvocation(node.arguments); + + if (node.name.asPlain == "get-function") { + var nameArgument = + node.arguments.named['name'] ?? node.arguments.positional.first; + if (nameArgument is! StringExpression || + (nameArgument as StringExpression).text.asPlain == null) { + warn("get-function call may require \$module parameter", + nameArgument.span); + return; + } + var fnName = nameArgument as StringExpression; + _patchNamespaceForFunction(fnName.text.asPlain, (name, namespace) { + var span = fnName.span; + if (fnName.hasQuotes) { + span = span.file.span(span.start.offset + 1, span.end.offset - 1); + } + addPatch(Patch(span, name)); + var beforeParen = node.span.end.offset - 1; + addPatch(Patch(node.span.file.span(beforeParen, beforeParen), + ', \$module: "$namespace"')); + }); + } + } + + /// Calls [patcher] when the function [name] requires a namespace and adds a + /// new use rule if necessary. + /// + /// [patcher] takes two arguments: the name used to refer to that function + /// when namespaced, and the namespace itself. The name will match the name + /// provided to the outer function except for built-in functions whose name + /// within a module differs from its original name. + void _patchNamespaceForFunction( + String name, void patcher(String name, String namespace)) { + if (name == null) return; + if (_localScope?.isLocalFunction(name) ?? false) return; + + var namespace = _globalFunctions.containsKey(name) + ? _namespaceForNode(_globalFunctions[name]) + : null; + + if (namespace == null) { + if (!builtInFunctionModules.containsKey(name)) return; + namespace = builtInFunctionModules[name]; + name = builtInFunctionNameChanges[name] ?? name; + _additionalUseRules.add("sass:$namespace"); + } + if (namespace != null) patcher(name, namespace); + } + + /// Declares the function within the current scope before visiting it. + @override + void visitFunctionRule(FunctionRule node) { + _declareFunction(node); + super.visitFunctionRule(node); + } + + /// Migrates @import to @use after migrating the imported file. + @override + void visitImportRule(ImportRule node) { + if (node.imports.first is StaticImport) { + super.visitImportRule(node); + return; + } + if (node.imports.length > 1) { + throw UnimplementedError( + "Migration of @import rule with multiple imports not supported."); + } + var import = node.imports.first as DynamicImport; + + if (_localScope != null) { + // TODO(jathak): Handle nested imports + return; + } + // TODO(jathak): Confirm that this import appears before other rules + + visitDependency(Uri.parse(import.url), _currentUrl); + _namespaces[_lastUrl] = namespaceForPath(import.url); + + // TODO(jathak): Support configurable variables + + addPatch(Patch(node.span, '@use ${import.span.text}')); + } + + /// Adds a namespace to any mixin include that requires it. + @override + void visitIncludeRule(IncludeRule node) { + super.visitIncludeRule(node); + if (_localScope?.isLocalMixin(node.name) ?? false) return; + if (!_globalMixins.containsKey(node.name)) return; + var namespace = _namespaceForNode(_globalMixins[node.name]); + if (namespace == null) return; + var endName = node.arguments.span.start.offset; + var startName = endName - node.name.length; + var nameSpan = node.span.file.span(startName, endName); + addPatch(Patch(nameSpan, "$namespace.${node.name}")); + } + + /// Declares the mixin within the current scope before visiting it. + @override + void visitMixinRule(MixinRule node) { + _declareMixin(node); + super.visitMixinRule(node); + } + + @override + void visitUseRule(UseRule node) { + // TODO(jathak): Handle existing @use rules. + throw UnsupportedError( + "Migrating files with existing @use rules is not yet supported"); + } + + /// Adds a namespace to any variable that requires it. + @override + visitVariableExpression(VariableExpression node) { + if (_localScope?.isLocalVariable(node.name) ?? false) { + return; + } + if (!_globalVariables.containsKey(node.name)) return; + var namespace = _namespaceForNode(_globalVariables[node.name]); + if (namespace == null) return; + addPatch(Patch(node.span, "\$$namespace.${node.name}")); + } + + /// Declares a variable within the current scope before visiting it. + @override + void visitVariableDeclaration(VariableDeclaration node) { + _declareVariable(node); + super.visitVariableDeclaration(node); + } + + /// Declares a variable within this stylesheet, in the current local scope if + /// it exists, or as a global variable otherwise. + void _declareVariable(VariableDeclaration node) { + if (_localScope == null || node.isGlobal) { + // TODO(jathak): Support configurable variables + _globalVariables[node.name] = node; + } else { + _localScope.variables.add(node.name); + } + } + + /// Declares a mixin within this stylesheet, in the current local scope if + /// it exists, or as a global mixin otherwise. + void _declareMixin(MixinRule node) { + if (_localScope == null) { + _globalMixins[node.name] = node; + } else { + _localScope.mixins.add(node.name); + } + } + + /// Declares a function within this stylesheet, in the current local scope if + /// it exists, or as a global function otherwise. + void _declareFunction(FunctionRule node) { + if (_localScope == null) { + _globalFunctions[node.name] = node; + } else { + _localScope.functions.add(node.name); + } + } + + /// Finds the namespace for the stylesheet containing [node], adding a new use + /// rule if necessary. + String _namespaceForNode(SassNode node) { + if (node.span.sourceUrl == _currentUrl) return null; + if (!_namespaces.containsKey(node.span.sourceUrl)) { + /// Add new use rule for indirect dependency + var relativePath = p.relative(node.span.sourceUrl.path, + from: p.dirname(_currentUrl.path)); + var basename = p.basenameWithoutExtension(relativePath); + if (basename.startsWith('_')) basename = basename.substring(1); + var simplePath = p.relative(p.join(p.dirname(relativePath), basename)); + _additionalUseRules.add(simplePath); + _namespaces[node.span.sourceUrl] = namespaceForPath(simplePath); + } + return _namespaces[node.span.sourceUrl]; + } +} diff --git a/lib/src/built_in_functions.dart b/lib/src/migrators/module/built_in_functions.dart similarity index 93% rename from lib/src/built_in_functions.dart rename to lib/src/migrators/module/built_in_functions.dart index 72f9677e..6ffd8ad9 100644 --- a/lib/src/built_in_functions.dart +++ b/lib/src/migrators/module/built_in_functions.dart @@ -1,3 +1,9 @@ +// Copyright 2019 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. + /// Mapping from existing built-in function name to the module it's now part of. const builtInFunctionModules = { "red": "color", diff --git a/lib/src/local_scope.dart b/lib/src/migrators/module/local_scope.dart similarity index 96% rename from lib/src/local_scope.dart rename to lib/src/migrators/module/local_scope.dart index febd5c1f..7307532b 100644 --- a/lib/src/local_scope.dart +++ b/lib/src/migrators/module/local_scope.dart @@ -4,7 +4,7 @@ // license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'utils.dart'; +import 'package:sass_migrator/src/utils.dart'; /// Keeps track of the scope of any members declared at the current level of /// the stylesheet. diff --git a/lib/src/patch.dart b/lib/src/patch.dart index b4742537..bc9ea133 100644 --- a/lib/src/patch.dart +++ b/lib/src/patch.dart @@ -1,4 +1,4 @@ -// Copyright 2018 Google LLC +// Copyright 2019 Google LLC // // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file or at diff --git a/lib/src/stylesheet_migration.dart b/lib/src/stylesheet_migration.dart deleted file mode 100644 index 161970de..00000000 --- a/lib/src/stylesheet_migration.dart +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright 2019 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 'dart:io'; - -import 'package:path/path.dart' as p; - -// 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/syntax.dart'; - -import 'patch.dart'; -import 'utils.dart'; - -/// Represents an in-progress migration for a stylesheet. -class StylesheetMigration { - /// The stylesheet this migration is for. - final Stylesheet stylesheet; - - /// The canonical path of this stylesheet. - final String path; - - /// The original contents of this stylesheet, prior to migration. - final String contents; - - /// The syntax used in this stylesheet. - final Syntax syntax; - - /// Namespaces of modules used in this stylesheet. - final namespaces = p.PathMap(); - - /// Set of additional use rules necessary for referencing members of - /// implicit dependencies / built-in modules. - /// - /// This set contains the path provided in the use rule, not the canonical - /// path (e.g. "a" rather than "dir/a.scss"). - final additionalUseRules = Set(); - - /// List of patches to be applied to this file. - final patches = []; - - /// Global variables declared with !default that could be configured. - final configurableVariables = normalizedSet(); - - StylesheetMigration._(this.stylesheet, this.path, this.contents, this.syntax); - - /// Creates a new migration for the stylesheet at [path]. - factory StylesheetMigration(String path) { - var contents = File(path).readAsStringSync(); - var syntax = Syntax.forPath(path); - var stylesheet = Stylesheet.parse(contents, syntax, url: path); - return StylesheetMigration._(stylesheet, path, contents, syntax); - } - - /// Returns the migrated contents of this file, based on [additionalUseRules] - /// and [patches], or null if no patches exist. - String get migratedContents { - if (patches.isEmpty) return null; - var semicolon = syntax == Syntax.sass ? "" : ";"; - var uses = additionalUseRules.map((use) => '@use "$use"$semicolon\n'); - var contents = Patch.applyAll(stylesheet.span.file, patches); - return uses.join("") + contents; - } - - /// Finds the namespace for the stylesheet containing [node], adding a new use - /// rule if necessary. - String namespaceForNode(SassNode node) { - var nodePath = p.fromUri(node.span.sourceUrl); - if (p.equals(nodePath, path)) return null; - if (!namespaces.containsKey(nodePath)) { - /// Add new use rule for indirect dependency - var relativePath = p.relative(nodePath, from: p.dirname(path)); - var basename = p.basenameWithoutExtension(relativePath); - if (basename.startsWith('_')) basename = basename.substring(1); - var simplePath = p.relative(p.join(p.dirname(relativePath), basename)); - additionalUseRules.add(simplePath); - namespaces[nodePath] = namespaceForPath(simplePath); - } - return namespaces[nodePath]; - } -} diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 55b78f88..d9b81ec5 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -4,17 +4,32 @@ // license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'package:path/path.dart' as p; +import 'dart:io'; + +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/importer/utils.dart' show resolveImportPath; +import 'package:sass/src/ast/sass.dart'; +import 'package:sass/src/ast/node.dart'; +import 'package:sass/src/importer/filesystem.dart'; export 'package:sass/src/utils.dart' show normalizedMap, normalizedSet; -/// Returns the canonical version of [path]. -String canonicalizePath(String path) { - return p.canonicalize(resolveImportPath(path)); +import 'patch.dart'; + +/// Returns the canonical version of [url]. +Uri canonicalize(Uri url) { + var importer = FilesystemImporter(Directory.current.path); + return importer.canonicalize(url); +} + +/// Parses the file at [url] into a stylesheet. +Stylesheet parseStylesheet(Uri url) { + var importer = FilesystemImporter(Directory.current.path); + url = importer.canonicalize(url); + var result = importer.load(url); + return Stylesheet.parse(result.contents, result.syntax, url: url); } /// Returns the default namespace for a use rule with [path]. @@ -22,3 +37,38 @@ String namespaceForPath(String path) { // TODO(jathak): Confirm that this is a valid Sass identifier return path.split('/').last.split('.').first; } + +/// Creates a patch that adds [text] immediately before [node]. +Patch patchBefore(AstNode node, String text) { + var start = node.span.start; + return Patch(start.file.span(start.offset, start.offset), text); +} + +/// Creates a patch that adds [text] immediately after [node]. +Patch patchAfter(AstNode node, String text) { + var end = node.span.end; + return Patch(end.file.span(end.offset, end.offset), text); +} + +/// Emits a warning with [message] and [context]; +void warn(String message, FileSpan context) { + print(context.message("WARNING - $message")); +} + +/// An exception thrown by a migrator. +class MigrationException { + final String message; + + /// The span that triggered this exception, or null if there is none. + final FileSpan span; + + MigrationException(this.message, {this.span}); + + String toString() { + if (span != null) { + return span.message(message); + } else { + return message; + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 4034355b..66f07954 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,13 +1,13 @@ -name: sass_module_migrator +name: sass_migrator version: 0.0.1-dev -description: A tool for migrating Sass files to the new module system +description: A tool for running migrations on Sass files environment: sdk: '>=2.1.0 <3.0.0' dependencies: - args: "^1.4.0" - sass: "^1.17.2" + args: "^1.5.1" + sass: "^1.18.0" source_span: "^1.4.0" path: "^1.6.0" diff --git a/test/migrations/module_configuration.hrx b/test/migrations/module_configuration.hrx deleted file mode 100644 index 67393975..00000000 --- a/test/migrations/module_configuration.hrx +++ /dev/null @@ -1,15 +0,0 @@ -<==> input/entrypoint.scss -$config: red; -$no-config: blue; -@import "library"; - -<==> input/_library.scss -$config: green !default; -$no-config: yellow; - -<==> output/entrypoint.scss -$config: red; -$no-config: blue; -@use "library" with ( - $config: red -); diff --git a/test/migrator_test.dart b/test/migrator_utils.dart similarity index 55% rename from test/migrator_test.dart rename to test/migrator_utils.dart index 888ab829..4f11ba3f 100644 --- a/test/migrator_test.dart +++ b/test/migrator_utils.dart @@ -1,4 +1,4 @@ -// Copyright 2018 Google LLC +// Copyright 2019 Google LLC // // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file or at @@ -6,48 +6,64 @@ import 'dart:io'; -import 'package:sass_module_migrator/src/migrator.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/importer/filesystem.dart'; import 'package:path/path.dart' as p; import 'package:term_glyph/term_glyph.dart' as glyph; import 'package:test/test.dart'; import 'package:test_descriptor/test_descriptor.dart' as d; -/// Runs all migration tests. See migrations/README.md for details. -void main() { +import 'package:sass_migrator/runner.dart'; + +/// Runs all tests for [migrator]. +/// +/// HRX files should be stored in `test/migrators//`. +void testMigrator(String migrator) { glyph.ascii = true; - var migrationTests = Directory("test/migrations"); - for (var file in migrationTests.listSync().whereType()) { - if (file.path.endsWith(".hrx")) { - test(p.basenameWithoutExtension(file.path), () => testHrx(file)); + var migrationTests = Directory("test/migrators/$migrator"); + group(migrator, () { + for (var file in migrationTests.listSync().whereType()) { + if (file.path.endsWith(".hrx")) { + test(p.basenameWithoutExtension(file.path), + () => _testHrx(file, migrator)); + } } - } + }); } -/// Run the migration test in [hrxFile]. See migrations/README.md for details. -testHrx(File hrxFile) async { - var files = HrxTestFiles(hrxFile.readAsStringSync()); +/// Run the migration test in [hrxFile]. +/// +/// See migrations/README.md for details. +_testHrx(File hrxFile, String migrator) async { + var files = _HrxTestFiles(hrxFile.readAsStringSync()); await files.unpack(); + Map migrated; var entrypoints = files.input.keys.where((path) => path.startsWith("entrypoint")); - p.PathMap migrated; - var migration = () { - migrated = migrateFiles(entrypoints, directory: d.sandbox); - }; - expect(migration, + var arguments = [migrator]..addAll(files.arguments)..addAll(entrypoints); + await expect( + () => IOOverrides.runZoned(() async { + migrated = await MigratorRunner().run(arguments); + }, getCurrentDirectory: () => Directory(d.sandbox)), prints(files.expectedLog?.replaceAll("\$TEST_DIR", d.sandbox) ?? "")); + var importer = FilesystemImporter(d.sandbox); for (var file in files.input.keys) { - expect(migrated[p.join(d.sandbox, file)], equals(files.output[file]), + expect(migrated[importer.canonicalize(Uri.parse(file))], + equals(files.output[file]), reason: 'Incorrect migration of $file.'); } } -class HrxTestFiles { +class _HrxTestFiles { Map input = {}; Map output = {}; + List arguments = []; String expectedLog; - HrxTestFiles(String hrxText) { + _HrxTestFiles(String hrxText) { // TODO(jathak): Replace this with an actual HRX parser. String filename; String contents; @@ -72,6 +88,8 @@ class HrxTestFiles { output[filename.substring(7)] = contents; } else if (filename == "log.txt") { expectedLog = contents; + } else if (filename == "arguments") { + arguments = contents.trim().split(" "); } } diff --git a/test/migrations/README.md b/test/migrators/README.md similarity index 73% rename from test/migrations/README.md rename to test/migrators/README.md index 1a9e84d5..ff7483b7 100644 --- a/test/migrations/README.md +++ b/test/migrators/README.md @@ -1,6 +1,18 @@ # Migration Tests -Each set of source files used for a test of the migrator is represented a single +Each migrator should have a `_test.dart` file that looks like: + +```dart +import '../migrator_utils.dart'; + +main() { + testMigrator(""); +} +``` + +and a directory `` that contains that migrator's HRX tests. + +Each set of source files used for a test of a migrator is represented a single [HRX archive](https://github.com/google/hrx). > Note: The test script does not currently use a proper HRX parser, so `<==>` is diff --git a/test/migrations/globally_shadowed_variable.hrx b/test/migrators/module/globally_shadowed_variable.hrx similarity index 87% rename from test/migrations/globally_shadowed_variable.hrx rename to test/migrators/module/globally_shadowed_variable.hrx index f0dec359..ee9987dc 100644 --- a/test/migrations/globally_shadowed_variable.hrx +++ b/test/migrators/module/globally_shadowed_variable.hrx @@ -1,3 +1,6 @@ +<==> arguments +--migrate-deps + <==> input/entrypoint.scss @import "library"; $variable : red; diff --git a/test/migrations/namespace_builtin_functions.hrx b/test/migrators/module/namespace_builtin_functions.hrx similarity index 100% rename from test/migrations/namespace_builtin_functions.hrx rename to test/migrators/module/namespace_builtin_functions.hrx diff --git a/test/migrations/namespace_function.hrx b/test/migrators/module/namespace_function.hrx similarity index 87% rename from test/migrations/namespace_function.hrx rename to test/migrators/module/namespace_function.hrx index a22bba52..3242a71c 100644 --- a/test/migrations/namespace_function.hrx +++ b/test/migrators/module/namespace_function.hrx @@ -1,3 +1,6 @@ +<==> arguments +--migrate-deps + <==> input/entrypoint.scss @import "library"; a { diff --git a/test/migrations/namespace_get_function.hrx b/test/migrators/module/namespace_get_function.hrx similarity index 97% rename from test/migrations/namespace_get_function.hrx rename to test/migrators/module/namespace_get_function.hrx index 56854188..2f9071ec 100644 --- a/test/migrations/namespace_get_function.hrx +++ b/test/migrators/module/namespace_get_function.hrx @@ -1,3 +1,6 @@ +<==> arguments +--migrate-deps + <==> input/entrypoint.scss @import "library"; @import "true"; diff --git a/test/migrations/namespace_indirect_reference.hrx b/test/migrators/module/namespace_indirect_reference.hrx similarity index 90% rename from test/migrations/namespace_indirect_reference.hrx rename to test/migrators/module/namespace_indirect_reference.hrx index dc092b0c..2013873e 100644 --- a/test/migrations/namespace_indirect_reference.hrx +++ b/test/migrators/module/namespace_indirect_reference.hrx @@ -1,3 +1,6 @@ +<==> arguments +--migrate-deps + <==> input/entrypoint.scss @import "direct"; a { diff --git a/test/migrations/namespace_mixin.hrx b/test/migrators/module/namespace_mixin.hrx similarity index 87% rename from test/migrations/namespace_mixin.hrx rename to test/migrators/module/namespace_mixin.hrx index 2e430623..719887c0 100644 --- a/test/migrations/namespace_mixin.hrx +++ b/test/migrators/module/namespace_mixin.hrx @@ -1,3 +1,6 @@ +<==> arguments +--migrate-deps + <==> input/entrypoint.scss @import "library"; a { diff --git a/test/migrations/namespace_variable.hrx b/test/migrators/module/namespace_variable.hrx similarity index 86% rename from test/migrations/namespace_variable.hrx rename to test/migrators/module/namespace_variable.hrx index ad165a97..e09fd486 100644 --- a/test/migrations/namespace_variable.hrx +++ b/test/migrators/module/namespace_variable.hrx @@ -1,3 +1,6 @@ +<==> arguments +--migrate-deps + <==> input/entrypoint.scss @import "library"; a { diff --git a/test/migrations/subdirectories.hrx b/test/migrators/module/subdirectories.hrx similarity index 92% rename from test/migrations/subdirectories.hrx rename to test/migrators/module/subdirectories.hrx index bd371af0..3d4e311c 100644 --- a/test/migrations/subdirectories.hrx +++ b/test/migrators/module/subdirectories.hrx @@ -1,3 +1,6 @@ +<==> arguments +--migrate-deps + <==> input/entrypoint.scss @import "folder/inner1"; $result: $a; diff --git a/test/migrators/module_test.dart b/test/migrators/module_test.dart new file mode 100644 index 00000000..eacd22b6 --- /dev/null +++ b/test/migrators/module_test.dart @@ -0,0 +1,11 @@ +// Copyright 2019 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 '../migrator_utils.dart'; + +main() { + testMigrator("module"); +} diff --git a/test/patch_test.dart b/test/patch_test.dart index c64b1e45..e2acff07 100644 --- a/test/patch_test.dart +++ b/test/patch_test.dart @@ -1,10 +1,10 @@ -// Copyright 2018 Google LLC +// Copyright 2019 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_module_migrator/src/patch.dart'; +import 'package:sass_migrator/src/patch.dart'; import 'package:source_span/source_span.dart'; import 'package:test/test.dart';