|
| 1 | +// Copyright 2024 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:sass_api/sass_api.dart'; |
| 8 | +import 'package:sass_migrator/src/migrators/module/reference_source.dart'; |
| 9 | +import 'package:source_span/source_span.dart'; |
| 10 | + |
| 11 | +import 'module/references.dart'; |
| 12 | +import '../migration_visitor.dart'; |
| 13 | +import '../migrator.dart'; |
| 14 | +import '../patch.dart'; |
| 15 | +import '../utils.dart'; |
| 16 | + |
| 17 | +/// Migrates off of legacy color functions. |
| 18 | +class ColorMigrator extends Migrator { |
| 19 | + final name = "color"; |
| 20 | + final description = "Migrates off of legacy color functions."; |
| 21 | + |
| 22 | + @override |
| 23 | + Map<Uri, String> migrateFile( |
| 24 | + ImportCache importCache, Stylesheet stylesheet, Importer importer) { |
| 25 | + var references = References(importCache, stylesheet, importer); |
| 26 | + var visitor = |
| 27 | + _ColorMigrationVisitor(references, importCache, migrateDependencies); |
| 28 | + var result = visitor.run(stylesheet, importer); |
| 29 | + missingDependencies.addAll(visitor.missingDependencies); |
| 30 | + return result; |
| 31 | + } |
| 32 | +} |
| 33 | + |
| 34 | +/// URL for the sass:color module. |
| 35 | +final _colorUrl = Uri(scheme: 'sass', path: 'color'); |
| 36 | + |
| 37 | +class _ColorMigrationVisitor extends MigrationVisitor { |
| 38 | + final References references; |
| 39 | + |
| 40 | + _ColorMigrationVisitor( |
| 41 | + this.references, super.importCache, super.migrateDependencies); |
| 42 | + |
| 43 | + /// The namespace of an existing `@use "sass:color"` rule in the current |
| 44 | + /// file, if any. |
| 45 | + String? _colorModuleNamespace; |
| 46 | + |
| 47 | + /// The set of all other namespaces already used in the current file. |
| 48 | + Set<String> _usedNamespaces = {}; |
| 49 | + |
| 50 | + @override |
| 51 | + void visitStylesheet(Stylesheet node) { |
| 52 | + var oldColorModuleNamespace = _colorModuleNamespace; |
| 53 | + var oldUsedNamespaces = _usedNamespaces; |
| 54 | + _colorModuleNamespace = null; |
| 55 | + _usedNamespaces = {}; |
| 56 | + // Check all the namespaces used by this file before visiting the |
| 57 | + // stylesheet, in case deprecated functions are called before all `@use` |
| 58 | + // rules. |
| 59 | + for (var useRule in node.uses) { |
| 60 | + if (_colorModuleNamespace != null || useRule.namespace == null) continue; |
| 61 | + if (useRule.url == _colorUrl) { |
| 62 | + _colorModuleNamespace = useRule.namespace; |
| 63 | + } else { |
| 64 | + _usedNamespaces.add(useRule.namespace!); |
| 65 | + } |
| 66 | + } |
| 67 | + super.visitStylesheet(node); |
| 68 | + _colorModuleNamespace = oldColorModuleNamespace; |
| 69 | + _usedNamespaces = oldUsedNamespaces; |
| 70 | + } |
| 71 | + |
| 72 | + @override |
| 73 | + void visitFunctionExpression(FunctionExpression node) { |
| 74 | + var source = references.sources[node]; |
| 75 | + if (source is! BuiltInSource || source.url != _colorUrl) return; |
| 76 | + switch (node.name) { |
| 77 | + case 'red' || 'green' || 'blue': |
| 78 | + _patchChannel(node, 'rgb'); |
| 79 | + case 'hue' || 'saturation' || 'lightness': |
| 80 | + _patchChannel(node, 'hsl'); |
| 81 | + case 'whiteness' || 'blackness': |
| 82 | + _patchChannel(node, 'hwb'); |
| 83 | + case 'alpha': |
| 84 | + _patchChannel(node); |
| 85 | + case 'adjust-hue': |
| 86 | + _patchAdjust(node, channel: 'hue', space: 'hsl'); |
| 87 | + case 'saturate' |
| 88 | + when node.arguments.named.length + node.arguments.positional.length != |
| 89 | + 1: |
| 90 | + _patchAdjust(node, channel: 'saturation', space: 'hsl'); |
| 91 | + case 'desaturate': |
| 92 | + _patchAdjust(node, channel: 'saturation', negate: true, space: 'hsl'); |
| 93 | + case 'transparentize' || 'fade-out': |
| 94 | + _patchAdjust(node, channel: 'alpha', negate: true); |
| 95 | + case 'opacify' || 'fade-in': |
| 96 | + _patchAdjust(node, channel: 'alpha'); |
| 97 | + case 'lighten': |
| 98 | + _patchAdjust(node, channel: 'lightness', space: 'hsl'); |
| 99 | + case 'darken': |
| 100 | + _patchAdjust(node, channel: 'lightness', negate: true, space: 'hsl'); |
| 101 | + default: |
| 102 | + return; |
| 103 | + } |
| 104 | + if (node.namespace == null) { |
| 105 | + addPatch( |
| 106 | + patchBefore( |
| 107 | + node, '${_getOrAddColorModuleNamespace(node.span.file)}.'), |
| 108 | + beforeExisting: true); |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + /// Returns the namespace used for the color module, adding a new `@use` rule |
| 113 | + /// if necessary. |
| 114 | + String _getOrAddColorModuleNamespace(SourceFile file) { |
| 115 | + if (_colorModuleNamespace == null) { |
| 116 | + _colorModuleNamespace = _chooseColorModuleNamespace(); |
| 117 | + var asClause = |
| 118 | + _colorModuleNamespace == 'color' ? '' : ' as $_colorModuleNamespace'; |
| 119 | + addPatch( |
| 120 | + Patch.insert(file.location(0), '@use "sass:color"$asClause;\n\n')); |
| 121 | + } |
| 122 | + return _colorModuleNamespace!; |
| 123 | + } |
| 124 | + |
| 125 | + /// Find an unused namespace for the sass:color module. |
| 126 | + String _chooseColorModuleNamespace() { |
| 127 | + if (!_usedNamespaces.contains('color')) return 'color'; |
| 128 | + if (!_usedNamespaces.contains('sass-color')) return 'sass-color'; |
| 129 | + var count = 2; |
| 130 | + var namespace = 'color$count'; |
| 131 | + while (_usedNamespaces.contains(namespace)) { |
| 132 | + namespace = 'color${++count}'; |
| 133 | + } |
| 134 | + return namespace; |
| 135 | + } |
| 136 | + |
| 137 | + /// Patches a deprecated channel function to use `color.channel` instead. |
| 138 | + void _patchChannel(FunctionExpression node, [String? colorSpace]) { |
| 139 | + addPatch(Patch(node.nameSpan, 'channel')); |
| 140 | + |
| 141 | + if (node.arguments.named.isEmpty) { |
| 142 | + addPatch(patchAfter( |
| 143 | + node.arguments.positional.last, |
| 144 | + ", '${node.name}'" |
| 145 | + "${colorSpace == null ? '' : ', \$space: $colorSpace'}")); |
| 146 | + } else { |
| 147 | + addPatch(patchAfter( |
| 148 | + [...node.arguments.positional, ...node.arguments.named.values].last, |
| 149 | + ", \$channel: '${node.name}'" |
| 150 | + "${colorSpace == null ? '' : ', \$space: $colorSpace'}")); |
| 151 | + } |
| 152 | + } |
| 153 | + |
| 154 | + /// Patches a deprecated adjustment function to use `color.adjust` instead. |
| 155 | + void _patchAdjust(FunctionExpression node, |
| 156 | + {required String channel, bool negate = false, String? space}) { |
| 157 | + addPatch(Patch(node.nameSpan, 'adjust')); |
| 158 | + switch (node.arguments) { |
| 159 | + case ArgumentInvocation(positional: [_, var adjustment]): |
| 160 | + addPatch(patchBefore(adjustment, '\$$channel: ${negate ? '-' : ''}')); |
| 161 | + if (negate && adjustment.needsParens) { |
| 162 | + addPatch(patchBefore(adjustment, '(')); |
| 163 | + addPatch(patchAfter(adjustment, ')')); |
| 164 | + } |
| 165 | + if (space != null) { |
| 166 | + addPatch(patchAfter(adjustment, ', \$space: $space')); |
| 167 | + } |
| 168 | + |
| 169 | + case ArgumentInvocation( |
| 170 | + named: {'amount': var adjustment} || {'degrees': var adjustment} |
| 171 | + ): |
| 172 | + var start = adjustment.span.start.offset - 1; |
| 173 | + while (adjustment.span.file.getText(start, start + 1) != r'$') { |
| 174 | + start--; |
| 175 | + } |
| 176 | + var argNameSpan = adjustment.span.file |
| 177 | + .location(start + 1) |
| 178 | + .pointSpan() |
| 179 | + .extendIfMatches('amount') |
| 180 | + .extendIfMatches('degrees'); |
| 181 | + addPatch(Patch(argNameSpan, channel)); |
| 182 | + if (negate) { |
| 183 | + addPatch(patchBefore(adjustment, '-')); |
| 184 | + if (adjustment.needsParens) { |
| 185 | + addPatch(patchBefore(adjustment, '(')); |
| 186 | + addPatch(patchAfter(adjustment, ')')); |
| 187 | + } |
| 188 | + } |
| 189 | + if (space != null) { |
| 190 | + addPatch(patchAfter(adjustment, ', \$space: $space')); |
| 191 | + } |
| 192 | + |
| 193 | + default: |
| 194 | + warn(node.span.message('Cannot migrate unexpected arguments.')); |
| 195 | + } |
| 196 | + } |
| 197 | +} |
| 198 | + |
| 199 | +extension _NeedsParens on Expression { |
| 200 | + /// Returns true if this expression needs parentheses when it's negated. |
| 201 | + bool get needsParens => switch (this) { |
| 202 | + BinaryOperationExpression() || |
| 203 | + UnaryOperationExpression() || |
| 204 | + FunctionExpression() => |
| 205 | + true, |
| 206 | + _ => false, |
| 207 | + }; |
| 208 | +} |
0 commit comments