Skip to content

Commit 37e279a

Browse files
fix(css): bound JSX candidates and handle valid attribute names
1 parent e722f4b commit 37e279a

3 files changed

Lines changed: 95 additions & 19 deletions

File tree

‎src/deadcode/_typescript.py‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ class _Token:
2525

2626

2727
_WORD = re.compile(r"[\w$]+", re.UNICODE)
28-
_JSX_OPENING = re.compile(r"<(?:[^\W\d]|\$)[\w$:.-]*(?=[\s/><])(?!\s*(?:=|extends\b))")
28+
_JSX_OPENING = re.compile(r"<(?:[^\W\d]|\$)[\w$:.-]*(?=[\s/><])(?!\s*=)")
29+
_JSX_EXTENDS = re.compile(r"\s+extends\s+(?![=>])")
2930
_JSX_CLOSING = re.compile(r"</(?:[\w$][\w$:.-]*\s*)?>")
3031
_CLASS_VALUE_OPERATORS = {":", "=", "+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "&&=", "||=", "??="}
3132
_PREFIX = {"keyof", "readonly", "unique", "typeof", "asserts", "abstract", "new"}
@@ -153,6 +154,20 @@ def _regex_context(tokens: list[_Token]) -> bool:
153154
return previous in _REGEX_PREFIX
154155

155156

157+
def _jsx_type_parameter_head(source: str, index: int) -> bool:
158+
opening = _JSX_OPENING.match(source, index)
159+
if not opening or not _JSX_EXTENDS.match(source, opening.end()):
160+
return False
161+
view = _RuntimeView(source[index : index + 8192], jsx=False)
162+
end = view.angle_end(0)
163+
if end is None or view.value(end) != "(" or end not in view.pairs:
164+
return False
165+
body = view.pairs[end] + 1
166+
if view.value(body) == ":":
167+
body = view.type_end(body + 1)
168+
return body is not None and view.value(body) in {"=>", "{"}
169+
170+
156171
def _jsx_element(source: str, index: int, depth: int) -> _Token | None:
157172
"""Read one complete element, exposing only its JavaScript expressions."""
158173
if depth > 80:
@@ -171,7 +186,7 @@ def _jsx_element(source: str, index: int, depth: int) -> _Token | None:
171186
name = source[index + 1 : opening.end()]
172187
index = opening.end()
173188
if index < len(source) and source[index] == "<":
174-
arguments = _RuntimeView(source[index:], jsx=False)
189+
arguments = _RuntimeView(source[index : index + 8192], jsx=False)
175190
end = arguments.angle_end(0)
176191
if end is None:
177192
return None
@@ -255,9 +270,11 @@ def _lex(
255270
if char.isspace():
256271
index += 1
257272
continue
273+
type_parameter_head = jsx and char == "<" and _jsx_type_parameter_head(source, index)
258274
if (
259275
jsx
260276
and char == "<"
277+
and not type_parameter_head
261278
and _regex_context(tokens)
262279
and (_JSX_OPENING.match(source, index) or source.startswith("<>", index))
263280
):
@@ -280,7 +297,7 @@ def _lex(
280297
continue
281298
if interpolation and char == "}" and not braces:
282299
return tokens, index, uncertain
283-
if jsx and char == "<" and (opening := _JSX_OPENING.match(source, index)):
300+
if jsx and char == "<" and not type_parameter_head and (opening := _JSX_OPENING.match(source, index)):
284301
jsx_tags.append((braces, opening.end()))
285302
elif char == ">" and jsx_tags and jsx_tags[-1][0] == braces:
286303
jsx_tags.pop()

‎src/deadcode/scanner.py‎

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -268,21 +268,22 @@ def _module_css_definitions(content: str, rel_path: str):
268268
# delimiter parsing separate from strings inside JavaScript expressions.
269269
_JS_LITERAL_PATTERN = re.compile(r"""'(?:\\[\s\S]|[^'\\\r\n])*'|"(?:\\[\s\S]|[^"\\\r\n])*"|`(?:\\[\s\S]|[^`\\])*`""")
270270
_JS_COMMENT_PATTERN = re.compile(r"//[^\r\n]*|/\*[\s\S]*?\*/")
271-
_JSX_TAG_PATTERN = re.compile(r"<(?:[^\W\d]|\$)[\w$:.-]*")
272-
_JSX_ATTRIBUTE_PATTERN = re.compile(r"[a-zA-Z_:][\w:.-]*")
271+
_JSX_TAG_PATTERN = re.compile(r"<(?:[^\W\d]|\$)[\w$:.-]*(?=[\s/>])")
272+
_JSX_ATTRIBUTE_PATTERN = re.compile(r"(?:[^\W\d]|[$:])[\w$:.-]*")
273273
_CLASS_PROPERTY_PATTERN = re.compile(r"""(?<![\w$])(?:className|class|['"]className['"]|['"]class['"])\s*:\s*""")
274274
_CLASSNAMES_CALL_PATTERN = re.compile(r"(?<![\w$])(?:classNames|clsx)\s*(?=\()")
275275
_JSX_ENTITY_PATTERN = re.compile(r"&(?:#[xX][0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*);")
276276
_HTML_CLASS_SPACE_PATTERN = re.compile(r"[ \t\r\n\f]+")
277277

278278

279-
def _js_delimited_end(content: str, start: int) -> int | None:
279+
def _js_delimited_end(content: str, start: int, *, limit: int | None = None) -> int | None:
280280
"""Find a closing JS delimiter without treating quoted braces as syntax."""
281281
closing = {"{": "}", "[": "]", "(": ")"}
282282
stack = [closing[content[start]]]
283283
index = start + 1
284-
while index < len(content):
285-
skipped = _JS_LITERAL_PATTERN.match(content, index) or _JS_COMMENT_PATTERN.match(content, index)
284+
limit = len(content) if limit is None else limit
285+
while index < limit:
286+
skipped = _JS_LITERAL_PATTERN.match(content, index, limit) or _JS_COMMENT_PATTERN.match(content, index, limit)
286287
if skipped:
287288
index = skipped.end()
288289
continue
@@ -356,42 +357,42 @@ def _jsx_class_attributes(content: str, attribute_positions: set[int] | None = N
356357
"""
357358
for tag in _JSX_TAG_PATTERN.finditer(content):
358359
index = tag.end()
359-
while index < len(content):
360+
limit = min(len(content), tag.start() + 8192)
361+
while index < limit:
360362
if content[index].isspace() or content[index] == "/":
361363
index += 1
362364
continue
363365
if content[index] == ">":
364366
break
365367
if content[index] == "{":
366-
end = _js_delimited_end(content, index)
368+
end = _js_delimited_end(content, index, limit=limit)
367369
if end is None:
368370
yield False, None
369371
break
370372
if content[index + 1 : end - 1].lstrip().startswith("..."):
371373
yield False, None # Spread props may supply any global class.
372374
index = end
373375
continue
374-
name = _JSX_ATTRIBUTE_PATTERN.match(content, index)
376+
name = _JSX_ATTRIBUTE_PATTERN.match(content, index, limit)
375377
if name is None:
376-
index += 1
377-
continue
378+
break # Not an attribute: do not walk the remaining file per candidate.
378379
wanted = name.group() in {"className", "class"}
379380
if wanted and attribute_positions is not None:
380381
attribute_positions.add(name.start())
381382
index = name.end()
382-
while index < len(content) and content[index].isspace():
383+
while index < limit and content[index].isspace():
383384
index += 1
384-
if index >= len(content) or content[index] != "=":
385+
if index >= limit or content[index] != "=":
385386
continue
386387
index += 1
387-
while index < len(content) and content[index].isspace():
388+
while index < limit and content[index].isspace():
388389
index += 1
389-
if index == len(content):
390+
if index == limit:
390391
if wanted:
391392
yield False, None
392393
break
393394
if content[index] in "'\"":
394-
end = content.find(content[index], index + 1)
395+
end = content.find(content[index], index + 1, limit)
395396
if end < 0:
396397
if wanted:
397398
yield False, None
@@ -400,7 +401,7 @@ def _jsx_class_attributes(content: str, attribute_positions: set[int] | None = N
400401
yield True, content[index + 1 : end]
401402
index = end + 1
402403
elif content[index] == "{":
403-
end = _js_delimited_end(content, index)
404+
end = _js_delimited_end(content, index, limit=limit)
404405
if wanted:
405406
yield False, content[index + 1 : end - 1] if end is not None else None
406407
if end is None:
@@ -410,6 +411,8 @@ def _jsx_class_attributes(content: str, attribute_positions: set[int] | None = N
410411
if wanted:
411412
yield False, None
412413
index += 1
414+
if index >= limit and limit < len(content):
415+
yield False, None # An oversized header leaves possible global uses uncertain.
413416

414417

415418
# tsconfig paths for path aliases
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Valid attribute identifiers and rejected candidates must preserve live CSS."""
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, _jsx_class_attributes
10+
11+
12+
@pytest.mark.parametrize("extension", ["tsx", "jsx"])
13+
@pytest.mark.parametrize("attributes", [r'extends="C:\"', r'extends data-path="C:\"'])
14+
def test_extends_attributes_preserve_module_rules(tmp_path, monkeypatch, extension, attributes):
15+
monkeypatch.setattr(time, "sleep", lambda _: None)
16+
(tmp_path / "Card.module.css").write_text(".live {}\n.unused {}\n", encoding="utf-8")
17+
(tmp_path / f"consumer.{extension}").write_text(
18+
"import styles from './Card.module.css';\n"
19+
f"const view = <Component {attributes} className={{styles.live}} />;\n",
20+
encoding="utf-8",
21+
)
22+
assert {finding.name for finding in DeadCodeScanner(tmp_path).scan().orphaned_css} == {"unused"}
23+
result = CliRunner().invoke(cli, ["-p", str(tmp_path), "remove", "-c", "orphaned_css"])
24+
assert result.exit_code == 0, result.output
25+
assert (tmp_path / "Card.module.css").read_text() == ".live {}\n"
26+
27+
28+
@pytest.mark.parametrize("attribute", ["é", "λ", "字段", "$value"])
29+
def test_unicode_attribute_names_do_not_expose_quoted_tag_delimiters(tmp_path, attribute):
30+
(tmp_path / "styles.css").write_text(".live {}\n.unused {}\n", encoding="utf-8")
31+
(tmp_path / "consumer.tsx").write_text(
32+
f'const view = <div {attribute}=">" className="live" />;\n', encoding="utf-8"
33+
)
34+
assert {finding.name for finding in DeadCodeScanner(tmp_path).scan().orphaned_css} == {"unused"}
35+
36+
37+
def test_rejected_tag_candidates_have_linear_character_work():
38+
class CountedText(str):
39+
reads = 0
40+
41+
def __getitem__(self, key):
42+
self.reads += 1
43+
return super().__getitem__(key)
44+
45+
content = CountedText(('const lesser=a<b;\nconst text="<name attr";\n') * 500)
46+
assert list(_jsx_class_attributes(content)) == []
47+
assert content.reads < 30 * len(content)
48+
49+
50+
def test_oversized_header_keeps_global_class_use_uncertain(tmp_path):
51+
(tmp_path / "styles.css").write_text(".live {}\n.unused {}\n", encoding="utf-8")
52+
(tmp_path / "consumer.tsx").write_text(
53+
"const view = <div " + "enabled " * 1500 + 'className="live" />;\n', encoding="utf-8"
54+
)
55+
findings = DeadCodeScanner(tmp_path).scan().orphaned_css
56+
assert findings and all(not finding.removable for finding in findings)

0 commit comments

Comments
 (0)