Skip to content

Commit ba55f44

Browse files
committed
fix: preserve complete CSS import bindings and global rule spans
1 parent e4000b8 commit ba55f44

4 files changed

Lines changed: 407 additions & 14 deletions

File tree

‎src/deadcode/scanner.py‎

Lines changed: 132 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,49 @@ def _module_css_definitions(content: str, rel_path: str):
236236
yield from definitions
237237

238238

239+
def _global_css_rule_spans(content: str):
240+
"""Map existing global findings to complete rules without adding selectors."""
241+
masked = _CSS_NONCODE_PATTERN.sub(
242+
lambda match: match.group() if match.group().startswith("\\") else re.sub(r"[^\n]", " ", match.group()), content
243+
)
244+
lines = [0, *(match.end() for match in re.finditer("\n", content))]
245+
spans: list[tuple[int, int, int | None]] = []
246+
blocks: list[int | None] = []
247+
start = 0
248+
parentheses = brackets = 0
249+
for match in _CSS_BLOCK_TOKEN_PATTERN.finditer(masked):
250+
token = match.group()
251+
if token.startswith("\\"):
252+
continue
253+
if token == "(":
254+
parentheses += 1
255+
elif token == ")":
256+
parentheses = max(0, parentheses - 1)
257+
elif token == "[":
258+
brackets += 1
259+
elif token == "]":
260+
brackets = max(0, brackets - 1)
261+
elif parentheses or brackets:
262+
continue
263+
elif token == "{":
264+
selector = masked[start : match.start()]
265+
selector_start = start + len(selector) - len(selector.lstrip())
266+
if selector.strip() and not selector.lstrip().startswith("@"):
267+
blocks.append(len(spans))
268+
spans.append((selector_start, match.start(), None))
269+
else:
270+
blocks.append(None)
271+
start = match.end()
272+
elif token == "}":
273+
if blocks and (index := blocks.pop()) is not None:
274+
header_start, opening, _ = spans[index]
275+
spans[index] = (header_start, opening, bisect_right(lines, match.start()))
276+
start = match.end()
277+
else:
278+
start = match.end()
279+
return spans, lines
280+
281+
239282
# import statements
240283
# Handles: import {Foo} from ..., import Foo from ..., import type {Foo} from ...,
241284
# import Default, {Named} from ..., import {type Foo} from ...
@@ -442,8 +485,11 @@ def _jsx_class_attributes(content: str, attribute_positions: set[int] | None = N
442485
)
443486

444487
# Computed keys are full quoted JavaScript literals, not identifier prefixes.
488+
# Keep non-ASCII continuation characters intact, including marks and ZWJ/ZWNJ.
489+
# Runtime token starts and the actual imported name still establish ownership.
490+
_JS_MODULE_BINDING = r"(?:[^\W\d]|[$])(?:[\w$]|[^\x00-\x7f\s])*"
445491
_CSS_MODULE_USAGE_PATTERN = re.compile(
446-
r"""(?<![\w$])([\w$]+)(?:\s*\??\.\s*([\w$]+)|\s*(?:\?\.)?\s*\[\s*"""
492+
rf"""(?<![\w$])({_JS_MODULE_BINDING})(?:\s*\??\.\s*([\w$]+)|\s*(?:\?\.)?\s*\[\s*"""
447493
r"""('(?:\\[\s\S]|[^'\\\r\n])*'|"(?:\\[\s\S]|[^"\\\r\n])*"|`(?:\\[\s\S]|[^`\\])*`)\s*\])""",
448494
)
449495

@@ -541,8 +587,46 @@ def _class_expression_values(expression: str, module_bindings: set[str]) -> tupl
541587
# Detect a CSS-module import so we know which source-file class accessors
542588
# correspond to module classes (``import styles from './x.module.css'``).
543589
_CSS_MODULE_IMPORT_PATTERN = re.compile(
544-
r"""import\s+(?:\*\s+as\s+|\{\s*default\s+as\s+)?([\w$]+)(?:\s*\}|\s*,\s*\{[^}]*\})?\s+from\s+['"]([^'"]*\.module\.css)['"]""",
590+
rf"""import\s+(?:\*\s+as\s+|\{{\s*default\s+as\s+)?({_JS_MODULE_BINDING})(?:\s*\}}|\s*,\s*\{{[^}}]*\}})?\s+from\s+['"]([^'"]*\.module\.css)['"]""",
591+
)
592+
_JS_IMPORT_GAP = rf"(?:\s|{_JS_COMMENT_PATTERN.pattern})*"
593+
_CSS_NAMED_IMPORT_PATTERN = re.compile(rf"import{_JS_IMPORT_GAP}\{{")
594+
_CSS_IMPORT_SOURCE_PATTERN = re.compile(rf"""{_JS_IMPORT_GAP}from{_JS_IMPORT_GAP}(['"])([^'"]*\.module\.css)\1""")
595+
_JS_IMPORT_NONCODE_PATTERN = re.compile(_JS_LITERAL_PATTERN.pattern + "|" + _JS_COMMENT_PATTERN.pattern)
596+
_CSS_IMPORT_PART_PATTERN = re.compile(_JS_LITERAL_PATTERN.pattern + "|,")
597+
_CSS_DEFAULT_SPECIFIER_PATTERN = re.compile(
598+
rf"""(default|'(?:\\[\s\S]|[^'\\\r\n])*'|"(?:\\[\s\S]|[^"\\\r\n])*")\s+as\s+({_JS_MODULE_BINDING})"""
545599
)
600+
_CSS_IMPORT_KEYWORD_PATTERN = re.compile(r"\bimport\b")
601+
602+
603+
def _css_module_imports(content: str, code_starts: set[int]):
604+
"""Read runtime default bindings from complete import-specifier lists."""
605+
for keyword in _CSS_IMPORT_KEYWORD_PATTERN.finditer(content):
606+
if keyword.start() not in code_starts:
607+
continue
608+
match = _CSS_MODULE_IMPORT_PATTERN.match(content, keyword.start())
609+
if match:
610+
yield match.group(1), match.group(2)
611+
match = _CSS_NAMED_IMPORT_PATTERN.match(content, keyword.start())
612+
if match is None:
613+
continue
614+
end = _js_delimited_end(content, match.end() - 1)
615+
source = _CSS_IMPORT_SOURCE_PATTERN.match(content, end) if end is not None else None
616+
if source is None:
617+
continue
618+
specifiers = _JS_IMPORT_NONCODE_PATTERN.sub(
619+
lambda token: " " if token.group().startswith(("//", "/*")) else token.group(),
620+
content[match.end() : end - 1],
621+
)
622+
start = 0
623+
# A comma inside a quoted import name is not a specifier separator.
624+
ends = [token.start() for token in _CSS_IMPORT_PART_PATTERN.finditer(specifiers) if token.group() == ","]
625+
for stop in [*ends, len(specifiers)]:
626+
specifier = _CSS_DEFAULT_SPECIFIER_PATTERN.fullmatch(specifiers[start:stop].strip())
627+
if specifier and (specifier.group(1) == "default" or _decode_js_string(specifier.group(1)) == "default"):
628+
yield specifier.group(2), source.group(2)
629+
start = stop + 1
546630

547631

548632
# ── Scanner ───────────────────────────────────────────────────────────
@@ -633,13 +717,20 @@ def scan(self) -> ScanResult:
633717
# Plain JS/TS can also export class props or classNames() values.
634718
if not self._is_css_file(rel_path):
635719
class_source = class_usage_source(content, rel_path)
636-
self._parse_classname_usage(class_source, used_css_classes, jsx=not rel_path.endswith(".ts"))
720+
self._parse_classname_usage(
721+
class_source, used_css_classes, jsx=not rel_path.endswith(".ts"), import_source=content
722+
)
637723

638724
# CSS modules can also be consumed by plain TypeScript/JavaScript
639725
# files, for example when exporting class-name maps to components.
640726
if not self._is_css_file(rel_path):
641727
self._parse_css_module_usage(
642-
class_source, rel_path, file_set, used_css_module_classes, unresolved_css_module_classes
728+
class_source,
729+
rel_path,
730+
file_set,
731+
used_css_module_classes,
732+
unresolved_css_module_classes,
733+
import_source=content,
643734
)
644735

645736
# Parse components
@@ -884,21 +975,46 @@ def _parse_css_classes(self, content: str, rel_path: str, css_classes: dict[str,
884975
for cls, definition in _module_css_definitions(content, rel_path):
885976
css_classes.setdefault(cls, []).append(definition)
886977
return
887-
for i, line in enumerate(content.splitlines(), 1):
978+
spans, lines = _global_css_rule_spans(content)
979+
starts = [span[0] for span in spans]
980+
offset = 0
981+
for i, line in enumerate(content.splitlines(keepends=True), 1):
888982
for m in _CSS_CLASS_PATTERN.finditer(line):
889983
identifier = m.group(1)
890984
if "\\" in identifier:
891985
# An unescaped colon following an escaped name begins a
892986
# pseudo-selector, not part of that class's decoded key.
893987
identifier = _CSS_IDENTIFIER_PATTERN.match(identifier).group()
894988
cls = _decode_css_identifier(identifier)
895-
css_classes.setdefault(cls, []).append(_CssDefinition(rel_path, i, True))
989+
index = bisect_right(starts, offset + m.start()) - 1
990+
definition = _CssDefinition(rel_path, i, True, single_line_rule=False)
991+
if index >= 0:
992+
header_start, opening, closing_line = spans[index]
993+
if offset + m.start() < opening:
994+
definition = _CssDefinition(
995+
rel_path,
996+
i,
997+
True,
998+
opening,
999+
bisect_right(lines, header_start) != bisect_right(lines, opening),
1000+
single_line_rule=closing_line == i,
1001+
)
1002+
css_classes.setdefault(cls, []).append(definition)
1003+
offset += len(line)
8961004

897-
def _parse_classname_usage(self, content: str, used_css_classes: set[str | None], *, jsx: bool = True) -> None:
1005+
def _parse_classname_usage(
1006+
self,
1007+
content: str,
1008+
used_css_classes: set[str | None],
1009+
*,
1010+
jsx: bool = True,
1011+
import_source: str | None = None,
1012+
) -> None:
8981013
"""Read raw JSX attributes and JavaScript literals using their own grammar."""
8991014
properties, calls, code_starts = class_value_positions(content, jsx=jsx)
9001015
module_bindings = {
901-
match.group(1) for match in _CSS_MODULE_IMPORT_PATTERN.finditer(content) if match.start() in code_starts
1016+
binding
1017+
for binding, _ in _css_module_imports(content if import_source is None else import_source, code_starts)
9021018
}
9031019

9041020
def consume(raw_jsx: bool, value: str | None) -> None:
@@ -942,6 +1058,8 @@ def _parse_css_module_usage(
9421058
file_set: set[str],
9431059
used_css_module_classes: dict[Path, set[str | None]],
9441060
unresolved_css_module_classes: set[str | None],
1061+
*,
1062+
import_source: str | None = None,
9451063
) -> None:
9461064
"""Extract CSS-module class names consumed via object accessors.
9471065
@@ -963,14 +1081,14 @@ def _parse_css_module_usage(
9631081
# only module accessors are treated as class names.
9641082
module_bindings: dict[str, Path | None] = {}
9651083
_, _, code_starts = class_value_positions(content, jsx=not rel_path.endswith(".ts"))
966-
for match in _CSS_MODULE_IMPORT_PATTERN.finditer(content):
967-
if match.start() not in code_starts:
968-
continue
969-
stylesheet = self._resolve_relative_module(rel_path, match.group(2), file_set)
1084+
# Type erasure preserves offsets and decides whether an import is live.
1085+
# Its original clause retains quoted export names and their aliases.
1086+
for binding, specifier in _css_module_imports(content if import_source is None else import_source, code_starts):
1087+
stylesheet = self._resolve_relative_module(rel_path, specifier, file_set)
9701088
if stylesheet is not None and stylesheet.endswith(".module.css"):
971-
module_bindings[match.group(1)] = (self.project_dir / stylesheet).resolve()
1089+
module_bindings[binding] = (self.project_dir / stylesheet).resolve()
9721090
else:
973-
module_bindings[match.group(1)] = None
1091+
module_bindings[binding] = None
9741092
if not module_bindings:
9751093
return
9761094
recognized = set()
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""Real CSS-module bindings and complete global rules survive removal."""
2+
3+
import time
4+
5+
import pytest
6+
from click.testing import CliRunner
7+
8+
from deadcode.cli import cli
9+
from deadcode.scanner import DeadCodeScanner
10+
11+
12+
def remove_css(project, monkeypatch):
13+
monkeypatch.setattr(time, "sleep", lambda _: None)
14+
before = {p.name: p.read_bytes() for p in project.glob("*.css")}
15+
findings = DeadCodeScanner(project).scan().orphaned_css
16+
preview = CliRunner().invoke(cli, ["-p", str(project), "remove", "-c", "orphaned_css", "--dry-run"])
17+
assert preview.exit_code == 0, preview.output
18+
assert {p.name: p.read_bytes() for p in project.glob("*.css")} == before
19+
removed = CliRunner().invoke(cli, ["-p", str(project), "remove", "-c", "orphaned_css"])
20+
assert removed.exit_code == 0, removed.output
21+
return findings
22+
23+
24+
@pytest.mark.parametrize(
25+
("statement", "binding"),
26+
[
27+
("import { default as styles, } from './x.module.css';", "styles"),
28+
("import { token, default as styles } from './x.module.css';", "styles"),
29+
("import { default as styles, token } from './x.module.css';", "styles"),
30+
(
31+
"import { first, default as styles, last as other, } from './x.module.css';",
32+
"styles",
33+
),
34+
("import {\n token,\n default as styles,\n} from './x.module.css';", "styles"),
35+
(
36+
"import { /* }, */ token, default /* bind */ as styles, } from './x.module.css';",
37+
"styles",
38+
),
39+
(
40+
"import { token, // other binding\n default as styles, } from './x.module.css';",
41+
"styles",
42+
),
43+
("import { type Token, default as styles, } from './x.module.css';", "styles"),
44+
("import { default as $css, token } from './x.module.css';", "$css"),
45+
("import { token, default as café, } from './x.module.css';", "café"),
46+
("import { default as 样式, token } from './x.module.css';", "样式"),
47+
('import { "default" as styles, token } from "./x.module.css";', "styles"),
48+
("import { 'default' as styles, } from './x.module.css';", "styles"),
49+
(
50+
'import { "x//y" as unused, default as styles, } from "./x.module.css";',
51+
"styles",
52+
),
53+
("import styles from './x.module.css';", "styles"),
54+
("import * as styles from './x.module.css';", "styles"),
55+
("import styles, { token } from './x.module.css';", "styles"),
56+
],
57+
)
58+
def test_complete_default_import_lists_keep_runtime_styles(tmp_path, monkeypatch, statement, binding):
59+
css = tmp_path / "x.module.css"
60+
css.write_text(".live {}\n.dead {}\n", encoding="utf-8")
61+
(tmp_path / "consumer.tsx").write_text(statement + f"\nexport const node = <p className={{{binding}.live}} />;\n")
62+
63+
findings = remove_css(tmp_path, monkeypatch)
64+
65+
assert [(f.name, f.removable) for f in findings] == [("dead", True)]
66+
assert css.read_text(encoding="utf-8") == ".live {}\n"
67+
68+
69+
@pytest.mark.parametrize(
70+
"source",
71+
[
72+
"import type { default as styles, } from './x.module.css';\ntype Name = typeof styles.live;",
73+
"import { type default as styles, } from './x.module.css';\ntype Name = typeof styles.live;",
74+
"import { default as styles, } from './x.module.css';\ntype Name = typeof styles.live;",
75+
"const text = \"import { default as styles, } from './x.module.css';\";\nconst styles = {}; void styles.live;",
76+
"/* import { default as styles, } from './x.module.css'; */\nconst styles = {}; void styles.live;",
77+
"const text = `import { default as styles, } from './x.module.css';`;\nconst styles = {}; void styles.live;",
78+
"import { other as styles, } from './x.module.css';\nconst text = 'styles.live';",
79+
"import { token /* default as styles */ } from './x.module.css';\nconst styles = {}; void styles.live;",
80+
],
81+
)
82+
def test_only_runtime_default_bindings_protect_module_styles(tmp_path, monkeypatch, source):
83+
css = tmp_path / "x.module.css"
84+
css.write_text(".live {}\n.dead {}\n", encoding="utf-8")
85+
(tmp_path / "consumer.tsx").write_text(source, encoding="utf-8")
86+
87+
findings = remove_css(tmp_path, monkeypatch)
88+
89+
assert {f.name for f in findings if f.removable} == {"live", "dead"}
90+
assert css.read_text(encoding="utf-8") == ""
91+
92+
93+
def test_multiple_default_aliases_and_neighboring_modules_keep_their_ownership(tmp_path, monkeypatch):
94+
first = tmp_path / "x.module.css"
95+
second = tmp_path / "y.module.css"
96+
first.write_text(".live {}\n.extra {}\n.dead {}\n", encoding="utf-8")
97+
second.write_text(".live {}\n.dead {}\n", encoding="utf-8")
98+
(tmp_path / "consumer.tsx").write_text(
99+
"import { default as first, token, default as second, } from './x.module.css';\n"
100+
"void first.live; void second.extra;\n",
101+
encoding="utf-8",
102+
)
103+
104+
findings = remove_css(tmp_path, monkeypatch)
105+
106+
assert {(f.file, f.name) for f in findings} == {
107+
("x.module.css", "dead"),
108+
("y.module.css", "live"),
109+
("y.module.css", "dead"),
110+
}
111+
assert first.read_text(encoding="utf-8") == ".live {}\n.extra {}\n"
112+
assert second.read_text(encoding="utf-8") == ""
113+
114+
115+
@pytest.mark.parametrize(
116+
("rule", "used", "expected_names"),
117+
[
118+
(".live,\n.unused\\:name {}\n", "live", {"unused:name"}),
119+
(".first,\n.last {}\n", "", {"first", "last"}),
120+
(".first, /* } */\n.last {}\n", "", {"first", "last"}),
121+
(".first,\n.last\\:name { color: red; }\n", "first", {"last:name"}),
122+
(".unused {\n color: red;\n}\n", "", {"unused"}),
123+
("@media (min-width: 1px) {\n.first,\n.last {}\n}\n", "first", {"last"}),
124+
('.first, [data-note="}"]\n.last {}\n', "first", {"last"}),
125+
(".first\\:name,\n.last\\:name {}\n", "first:name", {"last:name"}),
126+
(".first /* note */ ,\n.last {}\n", "first", {"last"}),
127+
(".live,\n.unused\\:name { content: '}'; /* } */ }\n", "live", {"unused:name"}),
128+
(".unused\n{ color: red; }\n", "", set()),
129+
(".unused\n{\n color: red;\n}\n", "", set()),
130+
],
131+
)
132+
def test_global_split_headers_and_rule_bodies_are_diagnostic_only(tmp_path, monkeypatch, rule, used, expected_names):
133+
css = tmp_path / "global.css"
134+
css.write_text(rule + ".standalone {}\n", encoding="utf-8")
135+
(tmp_path / "consumer.jsx").write_text(f'export const view = <p className="{used}" />;\n', encoding="utf-8")
136+
137+
findings = remove_css(tmp_path, monkeypatch)
138+
139+
assert {f.name for f in findings} == expected_names | {"standalone"}
140+
assert {f.name for f in findings if f.removable} == {"standalone"}
141+
assert css.read_text(encoding="utf-8") == rule
142+
143+
144+
@pytest.mark.parametrize(
145+
"rule",
146+
[
147+
".unused {}\n",
148+
".unused { content: '}'; }\n",
149+
".unused { /* { } */ color: red; }\n",
150+
],
151+
)
152+
def test_complete_global_one_line_rules_still_remove(tmp_path, monkeypatch, rule):
153+
css = tmp_path / "global.css"
154+
css.write_text(rule, encoding="utf-8")
155+
156+
findings = remove_css(tmp_path, monkeypatch)
157+
158+
assert [(f.name, f.removable) for f in findings] == [("unused", True)]
159+
assert css.read_text(encoding="utf-8") == ""

0 commit comments

Comments
 (0)