Skip to content

Commit be1553e

Browse files
authored
v6: Detect unused Sass imports (#42121)
* Remove unused imports * New script * Fix * More unused
1 parent 8ad6871 commit be1553e

82 files changed

Lines changed: 355 additions & 273 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

build/check-unused-imports.mjs

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
#!/usr/bin/env node
2+
3+
/*!
4+
* Script to detect unused SCSS @use statements.
5+
*
6+
* Copyright 2017-2026 The Bootstrap Authors
7+
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
8+
*/
9+
10+
import { readFileSync, readdirSync, existsSync } from 'node:fs'
11+
import path from 'node:path'
12+
13+
const DISABLE_FILE = 'check-unused-imports-disable'
14+
const DISABLE_NEXT_LINE = 'check-unused-imports-disable-next-line'
15+
const DISABLE_LINE = 'check-unused-imports-disable-line'
16+
17+
function findScssFiles(dirs) {
18+
const files = []
19+
for (const dir of dirs) {
20+
const resolvedDir = path.resolve(dir)
21+
if (!existsSync(resolvedDir)) {
22+
throw new Error(`Directory does not exist: ${dir}`)
23+
}
24+
25+
walk(resolvedDir, files)
26+
}
27+
28+
return files.sort()
29+
}
30+
31+
function walk(dir, results) {
32+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
33+
const full = path.join(dir, entry.name)
34+
if (entry.isDirectory()) {
35+
walk(full, results)
36+
} else if (entry.name.endsWith('.scss')) {
37+
results.push(full)
38+
}
39+
}
40+
}
41+
42+
function parseUseStatements(content) {
43+
const lines = content.split('\n')
44+
const uses = []
45+
46+
for (const line of lines) {
47+
const trimmed = line.trim()
48+
if (trimmed === '' || trimmed.startsWith('//')) {
49+
if (trimmed.includes(DISABLE_FILE)) {
50+
return []
51+
}
52+
53+
continue
54+
}
55+
56+
break
57+
}
58+
59+
for (let i = 0; i < lines.length; i++) {
60+
const trimmed = lines[i].trim()
61+
if (!trimmed.startsWith('@use ')) {
62+
continue
63+
}
64+
65+
// Suppression comments
66+
if (trimmed.includes(DISABLE_LINE)) {
67+
continue
68+
}
69+
70+
if (i > 0 && lines[i - 1].trim().includes(DISABLE_NEXT_LINE)) {
71+
continue
72+
}
73+
74+
if (/\bwith\s*\(/.test(trimmed)) {
75+
continue
76+
}
77+
78+
const match = trimmed.match(/^@use\s+["']([^"']+)["'](?:\s+as\s+(\*|[\w-]+))?/)
79+
if (!match) {
80+
continue
81+
}
82+
83+
const modulePath = match[1]
84+
const asClause = match[2]
85+
86+
let namespace
87+
let isGlob = false
88+
89+
if (asClause === '*') {
90+
isGlob = true
91+
namespace = '*'
92+
} else if (asClause) {
93+
namespace = asClause
94+
} else {
95+
namespace = modulePath.split('/').at(-1).replace(/^_/, '')
96+
}
97+
98+
uses.push({
99+
line: i + 1,
100+
modulePath,
101+
namespace,
102+
isGlob,
103+
isBuiltin: modulePath.startsWith('sass:')
104+
})
105+
}
106+
107+
return uses
108+
}
109+
110+
function resolveModule(modulePath, fromFile) {
111+
if (modulePath.startsWith('sass:')) {
112+
return null
113+
}
114+
115+
const fromDir = path.dirname(fromFile)
116+
const target = path.resolve(fromDir, modulePath)
117+
const dir = path.dirname(target)
118+
const base = path.basename(target)
119+
120+
const candidates = [
121+
path.join(dir, `_${base}.scss`),
122+
`${target}.scss`,
123+
path.join(target, '_index.scss'),
124+
path.join(target, 'index.scss')
125+
]
126+
127+
for (const c of candidates) {
128+
if (existsSync(c)) {
129+
return c
130+
}
131+
}
132+
133+
return null
134+
}
135+
136+
const exportCache = new Map()
137+
138+
function getModuleExports(filePath) {
139+
if (!filePath || !existsSync(filePath)) {
140+
return { variables: new Set(), mixins: new Set(), functions: new Set() }
141+
}
142+
143+
const real = path.resolve(filePath)
144+
if (exportCache.has(real)) {
145+
return exportCache.get(real)
146+
}
147+
148+
const exports = { variables: new Set(), mixins: new Set(), functions: new Set() }
149+
exportCache.set(real, exports)
150+
151+
const content = readFileSync(real, 'utf8')
152+
const lines = content.split('\n')
153+
154+
for (const line of lines) {
155+
const v = line.match(/^\$([a-zA-Z][\w-]*)\s*:/)
156+
if (v) {
157+
exports.variables.add(v[1])
158+
}
159+
160+
const m = line.match(/^@mixin\s+([a-zA-Z][\w-]*)/)
161+
if (m) {
162+
exports.mixins.add(m[1])
163+
}
164+
165+
const f = line.match(/^@function\s+([a-zA-Z][\w-]*)/)
166+
if (f) {
167+
exports.functions.add(f[1])
168+
}
169+
}
170+
171+
for (const line of lines) {
172+
const fwd = line.trim().match(/^@forward\s+["']([^"']+)["']/)
173+
if (fwd) {
174+
const resolved = resolveModule(fwd[1], real)
175+
if (resolved) {
176+
const fwdExports = getModuleExports(resolved)
177+
for (const variable of fwdExports.variables) {
178+
exports.variables.add(variable)
179+
}
180+
181+
for (const mixin of fwdExports.mixins) {
182+
exports.mixins.add(mixin)
183+
}
184+
185+
for (const fn of fwdExports.functions) {
186+
exports.functions.add(fn)
187+
}
188+
}
189+
}
190+
}
191+
192+
return exports
193+
}
194+
195+
function escapeRegExp(s) {
196+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
197+
}
198+
199+
function isNamespaceUsed(namespace, body) {
200+
return new RegExp(`(?<![\\w-])${escapeRegExp(namespace)}\\.`).test(body)
201+
}
202+
203+
function isGlobImportUsed(moduleExports, body) {
204+
for (const name of moduleExports.variables) {
205+
if (new RegExp(`\\$${escapeRegExp(name)}(?![\\w-])`).test(body)) {
206+
return true
207+
}
208+
}
209+
210+
for (const name of moduleExports.mixins) {
211+
if (new RegExp(`@include\\s+${escapeRegExp(name)}(?![\\w-])`).test(body)) {
212+
return true
213+
}
214+
}
215+
216+
for (const name of moduleExports.functions) {
217+
if (new RegExp(`(?<![\\w-])${escapeRegExp(name)}\\s*\\(`).test(body)) {
218+
return true
219+
}
220+
}
221+
222+
return false
223+
}
224+
225+
function getBody(content) {
226+
const lines = content.split('\n')
227+
const filtered = []
228+
let inBlockComment = false
229+
230+
for (const line of lines) {
231+
const trimmed = line.trim()
232+
if (!inBlockComment && trimmed.startsWith('@use ')) {
233+
continue
234+
}
235+
236+
if (!inBlockComment && trimmed.startsWith('@forward ')) {
237+
continue
238+
}
239+
240+
let current = line
241+
if (inBlockComment) {
242+
const end = current.indexOf('*/')
243+
if (end === -1) {
244+
continue
245+
}
246+
247+
current = current.slice(end + 2)
248+
inBlockComment = false
249+
}
250+
251+
for (;;) {
252+
const start = current.indexOf('/*')
253+
if (start === -1) {
254+
break
255+
}
256+
257+
const end = current.indexOf('*/', start + 2)
258+
if (end === -1) {
259+
current = current.slice(0, start)
260+
inBlockComment = true
261+
break
262+
}
263+
264+
current = `${current.slice(0, start)}${current.slice(end + 2)}`
265+
}
266+
267+
const singleCommentStart = current.indexOf('//')
268+
if (singleCommentStart !== -1) {
269+
current = current.slice(0, singleCommentStart)
270+
}
271+
272+
filtered.push(current)
273+
}
274+
275+
return filtered.join('\n')
276+
}
277+
278+
function isUseUnused(use, body, file) {
279+
if (use.isBuiltin) {
280+
const name = use.modulePath.split(':')[1]
281+
return !isNamespaceUsed(name, body)
282+
}
283+
284+
if (!use.isGlob) {
285+
return !isNamespaceUsed(use.namespace, body)
286+
}
287+
288+
const resolved = resolveModule(use.modulePath, file)
289+
if (!resolved) {
290+
return false
291+
}
292+
293+
const moduleExports = getModuleExports(resolved)
294+
const hasExports =
295+
moduleExports.variables.size > 0 ||
296+
moduleExports.mixins.size > 0 ||
297+
moduleExports.functions.size > 0
298+
299+
if (!hasExports) {
300+
return false
301+
}
302+
303+
return !isGlobImportUsed(moduleExports, body)
304+
}
305+
306+
function main() {
307+
const args = process.argv.slice(2)
308+
if (args.length === 0) {
309+
console.error('Usage: node check-unused-imports.mjs <dir1> [dir2] ...')
310+
process.exit(2)
311+
}
312+
313+
let files
314+
try {
315+
files = findScssFiles(args)
316+
} catch (error) {
317+
console.error(error.message)
318+
process.exit(2)
319+
}
320+
321+
let total = 0
322+
for (const file of files) {
323+
const content = readFileSync(file, 'utf8')
324+
const uses = parseUseStatements(content)
325+
if (uses.length === 0) {
326+
continue
327+
}
328+
329+
const body = getBody(content)
330+
for (const use of uses) {
331+
if (isUseUnused(use, body, file)) {
332+
const rel = path.relative(process.cwd(), file)
333+
console.log(`${rel}:${use.line}\tUnused @use "${use.modulePath}"`)
334+
total++
335+
}
336+
}
337+
}
338+
339+
if (total > 0) {
340+
console.log(`\nFound ${total} unused @use statement${total === 1 ? '' : 's'}`)
341+
process.exit(1)
342+
}
343+
}
344+
345+
main()

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"css-docs": "node build/generate-utilities-json.mjs",
4848
"css-lint": "npm-run-all --aggregate-output --continue-on-error --parallel css-lint-*",
4949
"css-lint-stylelint": "stylelint \"**/*.{css,scss}\" --cache --cache-location .cache/.stylelintcache",
50+
"css-lint-imports": "node build/check-unused-imports.mjs scss/ site/src/scss/",
5051
"css-lint-vars": "fusv scss/ site/src/scss/",
5152
"css-minify": "npm-run-all --aggregate-output --parallel css-minify-*",
5253
"css-minify-main": "node build/css-minify.mjs",

scss/_accordion.scss

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
@use "sass:map";
2-
@use "config" as *;
3-
@use "variables" as *;
41
@use "functions" as *;
52
@use "mixins/border-radius" as *;
63
@use "mixins/transition" as *;

scss/_alert.scss

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
@use "sass:map";
21
@use "config" as *;
32
@use "functions" as *;
4-
@use "theme" as *;
53
@use "variables" as *;
64
@use "mixins/border-radius" as *;
75
@use "mixins/tokens" as *;

scss/_avatar.scss

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
@use "sass:map";
22
@use "functions" as *;
3-
@use "variables" as *;
4-
@use "theme" as *;
53
@use "mixins/border-radius" as *;
64
@use "mixins/transition" as *;
75
@use "mixins/tokens" as *;

scss/_badge.scss

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
@use "sass:map";
2-
@use "colors" as *;
31
@use "functions" as *;
4-
@use "variables" as *;
5-
@use "theme" as *;
62
@use "mixins/border-radius" as *;
73
@use "mixins/gradients" as *;
84
@use "mixins/tokens" as *;

scss/_breadcrumb.scss

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
@use "sass:map";
2-
@use "sass:string";
3-
@use "config" as *;
4-
@use "variables" as *;
51
@use "functions" as *;
62
@use "mixins/border-radius" as *;
73
@use "mixins/transition" as *;

0 commit comments

Comments
 (0)