Skip to content

Commit 463b1f4

Browse files
authored
Improved late import hoisting (#282)
* Improved late import hoisting While 2.4.0 allowed the migrator to hoist late imports that did not emit CSS themselves, this additionally allows it to hoist them above safe at rules in the same file, even if the imports themselves do actually emit CSS. * Code review
1 parent 8207c2b commit 463b1f4

File tree

12 files changed

+213
-38
lines changed

12 files changed

+213
-38
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
## 2.4.2
2+
3+
### Module Migration
4+
5+
* Late `@import` rules can now be hoisted above safe at-rules in the same file.
6+
7+
* Fix a typo in the error message for late `@import` rules that could not be
8+
migrated.
9+
10+
* Fix a bug where hoisted `@use` rules with configuration would have incorrect
11+
syntax.
12+
113
## 2.4.1
214

315
### Module Migrator

lib/src/migrators/module.dart

Lines changed: 53 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import 'module/reference_source.dart';
2525
import 'module/references.dart';
2626
import 'module/unreferencable_members.dart';
2727
import 'module/unreferencable_type.dart';
28+
import 'module/use_allowed.dart';
2829

2930
/// Migrates stylesheets to the new module system.
3031
class ModuleMigrator extends Migrator {
@@ -99,17 +100,19 @@ class ModuleMigrator extends Migrator {
99100
'You must provide --remove-prefix with --forward=prefixed so we know '
100101
'which prefixed members to forward.');
101102
}
103+
var safeAtRules = (argResults!['safe-at-rule'] as List<String>).toSet();
102104

103-
var references = References(importCache, stylesheet, importer,
104-
safeAtRules: argResults!['safe-at-rule'] as List<String>);
105+
var references =
106+
References(importCache, stylesheet, importer, safeAtRules: safeAtRules);
105107
var visitor = _ModuleMigrationVisitor(
106108
importCache, references, globalResults!['load-path'] as List<String>,
107109
migrateDependencies: migrateDependencies,
108110
builtInOnly: builtInOnly,
109111
prefixesToRemove: (argResults!['remove-prefix'] as List<String>)
110112
.map((prefix) => prefix.replaceAll('_', '-')),
111113
forwards: forwards,
112-
unsafeHoist: argResults!['unsafe-hoist'] as bool);
114+
unsafeHoist: argResults!['unsafe-hoist'] as bool,
115+
safeAtRules: safeAtRules);
113116
var migrated = visitor.run(stylesheet, importer);
114117
_filesWithRenamedDeclarations.addAll(
115118
{for (var member in visitor.renamedMembers.keys) member.sourceUrl});
@@ -188,8 +191,9 @@ class _ModuleMigrationVisitor extends MigrationVisitor {
188191
/// or `@forward` rule, or null if none has been visited yet.
189192
FileLocation? _afterLastImport;
190193

191-
/// Whether @use and @forward are allowed in the current context.
192-
var _useAllowed = true;
194+
/// The state of whether `@use` and `@forward` are allowed at the current
195+
/// point in the file.
196+
var _useAllowed = UseAllowed.allowed;
193197

194198
/// Whether an import-only stylesheet should be generated.
195199
///
@@ -231,6 +235,10 @@ class _ModuleMigrationVisitor extends MigrationVisitor {
231235
/// emit CSS.
232236
final bool unsafeHoist;
233237

238+
/// CSS at rules that should be considered to not emit CSS for the purpose
239+
/// of hoisting late `@import` rules.
240+
final Set<String> safeAtRules;
241+
234242
/// Constructs a new module migration visitor.
235243
///
236244
/// [importCache] must be the same one used by [references].
@@ -248,7 +256,8 @@ class _ModuleMigrationVisitor extends MigrationVisitor {
248256
required this.builtInOnly,
249257
required this.unsafeHoist,
250258
Iterable<String> prefixesToRemove = const [],
251-
this.forwards = const {}})
259+
this.forwards = const {},
260+
this.safeAtRules = const {}})
252261
: loadPaths = List.unmodifiable(
253262
loadPaths.map((path) => p.toUri(p.absolute(path)).path)),
254263
prefixesToRemove = UnmodifiableSetView(prefixesToRemove.toSet());
@@ -470,7 +479,7 @@ class _ModuleMigrationVisitor extends MigrationVisitor {
470479
__additionalRelativeUseRules = {};
471480
_beforeFirstImport = null;
472481
_afterLastImport = null;
473-
_useAllowed = true;
482+
_useAllowed = UseAllowed.allowed;
474483
super.visitStylesheet(node);
475484
__namespaces = oldNamespaces;
476485
__forwardedUrls = oldForwardedUrls;
@@ -804,7 +813,7 @@ class _ModuleMigrationVisitor extends MigrationVisitor {
804813
/// Visits a `@function` rule, renaming if necessary.
805814
@override
806815
void visitFunctionRule(FunctionRule node) {
807-
_useAllowed = false;
816+
_useAllowed = UseAllowed.notAllowed;
808817
_renameReference(nameSpan(node), MemberDeclaration(node));
809818
super.visitFunctionRule(node);
810819
}
@@ -816,7 +825,7 @@ class _ModuleMigrationVisitor extends MigrationVisitor {
816825
var (staticImports, dynamicImports) =
817826
partitionOnType<Import, StaticImport, DynamicImport>(node.imports);
818827
if (dynamicImports.isEmpty) {
819-
_useAllowed = false;
828+
_useAllowed = _useAllowed.lowerToRequiresHoist();
820829
return;
821830
}
822831
String rulesText;
@@ -853,10 +862,11 @@ class _ModuleMigrationVisitor extends MigrationVisitor {
853862
visitDependency(ruleUrl, import.span);
854863
_upstreamStylesheets.remove(currentUrl);
855864
}
856-
} else if (_useAllowed) {
865+
} else if (_useAllowed.canMigrateInPlace) {
857866
inPlaceUseRules.addAll(_migrateImportToRules(ruleUrl, import.span));
858867
} else if (!isNested &&
859-
(!(references.fileEmitsCss[canonicalUrl] ?? true) ||
868+
(_useAllowed.canAlwaysSafelyHoist ||
869+
!(references.fileEmitsCss[canonicalUrl] ?? true) ||
860870
(unsafeHoist &&
861871
references.anyMemberReferenced(
862872
canonicalUrl, currentUrl)))) {
@@ -878,14 +888,14 @@ class _ModuleMigrationVisitor extends MigrationVisitor {
878888
} else {
879889
addPatch(Patch(node.span, rulesText));
880890
}
881-
if (_useAllowed) {
891+
if (_useAllowed.canMigrateInPlace) {
882892
_beforeFirstImport ??= node.span.start;
883893
_afterLastImport = afterImport(node,
884894
shouldHaveSemicolon: !currentUrl.path.endsWith('.sass'));
885895
}
886896

887897
if (staticImports.isNotEmpty) {
888-
_useAllowed = false;
898+
_useAllowed = _useAllowed.lowerToRequiresHoist();
889899
addPatch(Patch.insert(
890900
_afterLastImport ?? node.span.file.location(0),
891901
'$indent@import ' +
@@ -906,7 +916,7 @@ class _ModuleMigrationVisitor extends MigrationVisitor {
906916
/// redirect to a different path to be migrated in-place.
907917
List<String> _migrateImportToRules(Uri ruleUrl, FileSpan context) {
908918
var (canonicalUrl, config, forwardForConfig) =
909-
_migrateImportCommon(ruleUrl, context);
919+
_migrateImportCommon(ruleUrl, context, toLoadCss: false);
910920

911921
var asClause = '';
912922
var defaultNamespace = namespaceForPath(ruleUrl.path);
@@ -955,7 +965,7 @@ class _ModuleMigrationVisitor extends MigrationVisitor {
955965
}
956966

957967
var (canonicalUrl, config, forwardForConfig) =
958-
_migrateImportCommon(ruleUrl, context);
968+
_migrateImportCommon(ruleUrl, context, toLoadCss: true);
959969
if (forwardForConfig != null) {
960970
throw MigrationSourceSpanException(
961971
"This declaration attempts to override a default value in an "
@@ -990,7 +1000,12 @@ class _ModuleMigrationVisitor extends MigrationVisitor {
9901000
/// `meta.load-css`. The third is a string of variables that should be added
9911001
/// to a `show` clause of a `@forward` rule so that they can be configured by
9921002
/// an upstream file.
993-
(Uri, String?, String?) _migrateImportCommon(Uri ruleUrl, FileSpan context) {
1003+
///
1004+
/// When [toLoadCss] is false, this uses the variable syntax for configuration
1005+
/// used by `@use`. When it is true, it instead uses the map syntax used by
1006+
/// `meta.load-css()`.
1007+
(Uri, String?, String?) _migrateImportCommon(Uri ruleUrl, FileSpan context,
1008+
{required bool toLoadCss}) {
9941009
var oldConfiguredVariables = __configuredVariables;
9951010
__configuredVariables = {};
9961011
_upstreamStylesheets.add(currentUrl);
@@ -1057,7 +1072,7 @@ class _ModuleMigrationVisitor extends MigrationVisitor {
10571072
var end = start + semicolon.length;
10581073
if (span.file.span(end, end + 1).text == '\n') end++;
10591074
addPatch(patchDelete(span.file.span(start, end)));
1060-
var nameFormat = _useAllowed ? '\$$name' : '"$name"';
1075+
var nameFormat = toLoadCss ? '"$name"' : '\$$name';
10611076
configured.add("$nameFormat: ${variable.member.expression}");
10621077
}
10631078
});
@@ -1157,7 +1172,7 @@ class _ModuleMigrationVisitor extends MigrationVisitor {
11571172
/// Adds a namespace to any mixin include that requires it.
11581173
@override
11591174
void visitIncludeRule(IncludeRule node) {
1160-
_useAllowed = false;
1175+
_useAllowed = UseAllowed.notAllowed;
11611176
super.visitIncludeRule(node);
11621177
if (node.namespace != null) return;
11631178
if (builtInOnly && references.sources[node] is! BuiltInSource) return;
@@ -1178,7 +1193,7 @@ class _ModuleMigrationVisitor extends MigrationVisitor {
11781193
/// Visits a `@mixin` rule, renaming it if necessary.
11791194
@override
11801195
void visitMixinRule(MixinRule node) {
1181-
_useAllowed = false;
1196+
_useAllowed = UseAllowed.notAllowed;
11821197
if (!builtInOnly) _renameReference(nameSpan(node), MemberDeclaration(node));
11831198
super.visitMixinRule(node);
11841199
}
@@ -1421,84 +1436,88 @@ class _ModuleMigrationVisitor extends MigrationVisitor {
14211436
/// Disallows `@use` after `@at-root` rules.
14221437
@override
14231438
void visitAtRootRule(AtRootRule node) {
1424-
_useAllowed = false;
1439+
_useAllowed = UseAllowed.notAllowed;
14251440
super.visitAtRootRule(node);
14261441
}
14271442

1428-
/// Disallows `@use` after at-rules.
1443+
/// Disallows `@use` after at-rules depending on [safeAtRules].
14291444
@override
14301445
void visitAtRule(AtRule node) {
1431-
_useAllowed = false;
1446+
var oldUseAllowed = _useAllowed;
1447+
_useAllowed = UseAllowed.notAllowed;
14321448
super.visitAtRule(node);
1449+
if (safeAtRules.contains(node.name.asPlain)) {
1450+
_useAllowed = oldUseAllowed.lowerToRequiresHoist();
1451+
}
14331452
}
14341453

1435-
/// Disallows `@use` after `@debug` rules.
1454+
/// Requires hoisting `@use` after `@debug` rules.
14361455
@override
14371456
void visitDebugRule(DebugRule node) {
1438-
_useAllowed = false;
1457+
_useAllowed = _useAllowed.lowerToRequiresHoist();
14391458
super.visitDebugRule(node);
14401459
}
14411460

14421461
/// Disallows `@use` after `@each` rules.
14431462
@override
14441463
void visitEachRule(EachRule node) {
1445-
_useAllowed = false;
1464+
_useAllowed = UseAllowed.notAllowed;
14461465
super.visitEachRule(node);
14471466
}
14481467

14491468
/// Disallows `@use` after `@error` rules.
14501469
@override
14511470
void visitErrorRule(ErrorRule node) {
1452-
_useAllowed = false;
1471+
_useAllowed = UseAllowed.notAllowed;
14531472
super.visitErrorRule(node);
14541473
}
14551474

14561475
/// Disallows `@use` after `@for` rules.
14571476
@override
14581477
void visitForRule(ForRule node) {
1459-
_useAllowed = false;
1478+
_useAllowed = UseAllowed.notAllowed;
14601479
super.visitForRule(node);
14611480
}
14621481

14631482
/// Disallows `@use` after `@if` rules.
14641483
@override
14651484
void visitIfRule(IfRule node) {
1466-
_useAllowed = false;
1485+
_useAllowed = UseAllowed.notAllowed;
14671486
super.visitIfRule(node);
14681487
}
14691488

14701489
/// Disallows `@use` after `@media` rules.
14711490
@override
14721491
void visitMediaRule(MediaRule node) {
1473-
_useAllowed = false;
1492+
_useAllowed = UseAllowed.notAllowed;
14741493
super.visitMediaRule(node);
14751494
}
14761495

14771496
/// Disallows `@use` after style rules.
14781497
@override
14791498
void visitStyleRule(StyleRule node) {
1480-
_useAllowed = false;
1499+
_useAllowed = UseAllowed.notAllowed;
14811500
super.visitStyleRule(node);
14821501
}
14831502

14841503
/// Disallows `@use` after `@supports` rules.
14851504
@override
14861505
void visitSupportsRule(SupportsRule node) {
1487-
_useAllowed = false;
1506+
_useAllowed = UseAllowed.notAllowed;
14881507
super.visitSupportsRule(node);
14891508
}
14901509

1491-
/// Disallows `@use` after `@warn` rules.
1510+
/// Requires hoisting `@use` after `@warn` rules.
14921511
@override
14931512
void visitWarnRule(WarnRule node) {
1494-
_useAllowed = false;
1513+
_useAllowed = _useAllowed.lowerToRequiresHoist();
14951514
super.visitWarnRule(node);
14961515
}
14971516

14981517
/// Disallows `@use` after `@while` rules.
14991518
@override
15001519
void visitWhileRule(WhileRule node) {
1501-
_useAllowed = false;
1520+
_useAllowed = UseAllowed.notAllowed;
15021521
super.visitWhileRule(node);
15031522
}
15041523
}

lib/src/migrators/module/references.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,8 @@ class _ReferenceVisitor extends ScopedAstVisitor {
257257
/// The last `@forward` rule to be visited that was not an import-only file.
258258
ForwardRule? _lastRegularForward;
259259

260-
/// CSS at-rules that should be considered to emit CSS.
260+
/// CSS at rules that should be considered to not emit CSS for the purpose
261+
/// of hoisting late `@import` rules.
261262
final Set<String> safeAtRules;
262263

263264
_ReferenceVisitor(this.importCache, this.safeAtRules);

lib/src/migrators/module/unreferencable_type.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ class UnreferencableType {
5959
"containing ${type}s\nand one emitting CSS will allow the migrator "
6060
"to safely migrate it by hoisting\nthe one containing members to "
6161
"the top of the file and converting the one\nemitting CSS to "
62-
"meta.load-css().\n\nAlternatively, pass --force-hoist to force the "
62+
"meta.load-css().\n\nAlternatively, pass --unsafe-hoist to force the "
6363
"migrator to hoist all\nlate imports containing Sass members, "
6464
"which may reorder your CSS.",
6565
reference.span,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright 2025 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:meta/meta.dart';
8+
9+
/// An enum of potential states for whether unnested `@import`s at the current
10+
/// point in the file can be migrated.
11+
@sealed
12+
class UseAllowed {
13+
/// Status when `@use` and `@forward` are allowed at this point in the file.
14+
static const allowed = UseAllowed._('allowed');
15+
16+
/// Status when `@use` and `@forward` are not allowed at this point in the
17+
/// file, but they can be safely hoisted to the top of the file because the
18+
/// only rules encountered so far are dependency rules (including plain CSS
19+
/// `@import` rules) and designated safe at rules.
20+
static const requiresHoist = UseAllowed._('requiresHoist');
21+
22+
/// Status when `@use` and `@forward` are not allowed at this point in the
23+
/// file and cannot necessarily be safely hoisted to the top of the file.
24+
///
25+
/// Migrated `@import`s may still be hoisted at this point if they do not
26+
/// emit any CSS themselves or if `--unsafe-hoist` is passed.
27+
static const notAllowed = UseAllowed._('notAllowed');
28+
29+
/// Identifier for this status
30+
final String id;
31+
32+
const UseAllowed._(this.id);
33+
34+
/// Returns [requiresHoist] unless [this] is already [notAllowed], in which
35+
/// case it should remain [notAllowed].
36+
UseAllowed lowerToRequiresHoist() =>
37+
switch (this) { notAllowed => notAllowed, _ => requiresHoist };
38+
39+
/// Returns true when `@import` rules can be migrated in place in this state.
40+
bool get canMigrateInPlace => this == allowed;
41+
42+
/// Returns true when all `@import` rules can be safely hoisted in this state.
43+
bool get canAlwaysSafelyHoist => this != notAllowed;
44+
}

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: sass_migrator
2-
version: 2.4.1
2+
version: 2.4.2
33
description: A tool for running migrations on Sass files
44
homepage: https://github.com/sass/migrator
55

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<==> arguments
2+
--migrate-deps
3+
4+
<==> input/entrypoint.scss
5+
a { b: c; }
6+
$config: red;
7+
$no-config: blue;
8+
@import "library";
9+
10+
<==> input/_library.scss
11+
$config: green !default;
12+
$no-config: yellow;
13+
14+
<==> output/entrypoint.scss
15+
@use "library" with ($config: red);
16+
17+
a { b: c; }
18+
$no-config: blue;

test/migrators/module/late_imports/css_and_references.hrx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ and one emitting CSS will allow the migrator to safely migrate it by hoisting
2626
the one containing members to the top of the file and converting the one
2727
emitting CSS to meta.load-css().
2828

29-
Alternatively, pass --force-hoist to force the migrator to hoist all
29+
Alternatively, pass --unsafe-hoist to force the migrator to hoist all
3030
late imports containing Sass members, which may reorder your CSS.
3131
,
3232
8 | e: $var;

0 commit comments

Comments
 (0)