Skip to content

Commit e0ed3ba

Browse files
fix(css): retain Unicode and generic JSX component boundaries
1 parent b697695 commit e0ed3ba

3 files changed

Lines changed: 46 additions & 5 deletions

File tree

‎src/deadcode/_typescript.py‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ class _Token:
2121
expressions: tuple[tuple[int, int], ...] = ()
2222
complete: bool = True
2323
uncertain: bool = False
24+
type_spans: tuple[tuple[int, int], ...] = ()
2425

2526

2627
_WORD = re.compile(r"[\w$]+", re.UNICODE)
27-
_JSX_OPENING = re.compile(r"<[a-zA-Z][\w:.-]*(?=[\s/>])(?!\s*(?:=|extends\b))")
28+
_JSX_OPENING = re.compile(r"<(?:[^\W\d]|\$)[\w$:.-]*(?=[\s/><])(?!\s*(?:=|extends\b))")
2829
_JSX_CLOSING = re.compile(r"</(?:[\w$][\w$:.-]*\s*)?>")
2930
_CLASS_VALUE_OPERATORS = {":", "=", "+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "&&=", "||=", "??="}
3031
_PREFIX = {"keyof", "readonly", "unique", "typeof", "asserts", "abstract", "new"}
@@ -158,6 +159,7 @@ def _jsx_element(source: str, index: int, depth: int) -> _Token | None:
158159
return None
159160
start = index
160161
expressions = []
162+
type_spans = []
161163
uncertain = False
162164
if source.startswith("<>", index):
163165
name = ""
@@ -168,10 +170,23 @@ def _jsx_element(source: str, index: int, depth: int) -> _Token | None:
168170
return None
169171
name = source[index + 1 : opening.end()]
170172
index = opening.end()
173+
if index < len(source) and source[index] == "<":
174+
arguments = _RuntimeView(source[index:], jsx=False)
175+
end = arguments.angle_end(0)
176+
if end is None:
177+
return None
178+
argument_end = index + arguments.tokens[end - 1].end
179+
type_spans.append((index, argument_end))
180+
index = argument_end
171181
while index < len(source):
172182
if source.startswith("/>", index):
173183
return _Token(
174-
source[start : index + 2], start, index + 2, expressions=tuple(expressions), uncertain=uncertain
184+
source[start : index + 2],
185+
start,
186+
index + 2,
187+
expressions=tuple(expressions),
188+
uncertain=uncertain,
189+
type_spans=tuple(type_spans),
175190
)
176191
if source[index] == ">":
177192
index += 1
@@ -189,7 +204,7 @@ def _jsx_element(source: str, index: int, depth: int) -> _Token | None:
189204
uncertain |= nested_uncertain
190205
index = end + 1
191206
elif source[index] == "<":
192-
return None # Generic JSX arguments retain the existing token path.
207+
return None
193208
else:
194209
index += 1
195210
else:
@@ -200,13 +215,19 @@ def _jsx_element(source: str, index: int, depth: int) -> _Token | None:
200215
if not closing or source[index + 2 : closing.end() - 1].strip() != name:
201216
return None
202217
return _Token(
203-
source[start : closing.end()], start, closing.end(), expressions=tuple(expressions), uncertain=uncertain
218+
source[start : closing.end()],
219+
start,
220+
closing.end(),
221+
expressions=tuple(expressions),
222+
uncertain=uncertain,
223+
type_spans=tuple(type_spans),
204224
)
205225
if source[index] == "<":
206226
child = _jsx_element(source, index, depth + 1)
207227
if child is None:
208228
return None
209229
expressions.extend(child.expressions)
230+
type_spans.extend(child.type_spans)
210231
uncertain |= child.uncertain
211232
index = child.end
212233
elif source[index] == "{":
@@ -874,6 +895,11 @@ def render(self, path: str, depth: int) -> str:
874895
for token in self.tokens:
875896
for start, end in token.expressions:
876897
output[start:end] = class_usage_source(self.source[start:end], path, depth=depth + 1)
898+
if path.endswith(".tsx"):
899+
for start, end in token.type_spans:
900+
for index in range(start, end):
901+
if output[index] not in "\r\n":
902+
output[index] = " "
877903
merged = []
878904
for start, end in sorted(self.erased):
879905
if merged and start <= merged[-1][1]:

‎src/deadcode/scanner.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ 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"<[a-zA-Z][\w:.-]*")
271+
_JSX_TAG_PATTERN = re.compile(r"<(?:[^\W\d]|\$)[\w$:.-]*")
272272
_JSX_ATTRIBUTE_PATTERN = re.compile(r"[a-zA-Z_:][\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*(?=\()")

‎tests/test_jsx_module_attribute_quotes.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,18 @@ def test_typescript_assertion_with_apparent_closing_tag_keeps_live_access(tmp_pa
111111
)
112112
findings = DeadCodeScanner(tmp_path).scan().orphaned_css
113113
assert {finding.name for finding in findings} == {"unused"}
114+
115+
116+
@pytest.mark.parametrize("tag", ["Élément", "Button<string>"])
117+
def test_generic_and_unicode_jsx_keep_module_and_literal_classes(tmp_path, tag):
118+
(tmp_path / "Card.module.css").write_text(".card {}\n.unused {}\n", encoding="utf-8")
119+
(tmp_path / "global.css").write_text(".literal {}\n.unusedGlobal {}\n", encoding="utf-8")
120+
closing = tag.split("<")[0]
121+
(tmp_path / "consumer.tsx").write_text(
122+
"import styles from './Card.module.css';\n"
123+
f'<{tag} className="literal">It\'s <span className={{styles.card}} /></{closing}>;\n',
124+
encoding="utf-8",
125+
)
126+
findings = DeadCodeScanner(tmp_path).scan().orphaned_css
127+
assert {finding.name for finding in findings} == {"unused", "unusedGlobal"}
128+
assert all(finding.removable for finding in findings)

0 commit comments

Comments
 (0)