diff --git a/src/deadcode/_typescript.py b/src/deadcode/_typescript.py index 36b7285..20037d7 100644 --- a/src/deadcode/_typescript.py +++ b/src/deadcode/_typescript.py @@ -27,6 +27,7 @@ class _Token: _WORD = re.compile(r"[\w$]+", re.UNICODE) _JSX_OPENING = re.compile(r"<(?:[^\W\d]|\$|[^\x00-\x7f\s])(?:[\w$:.-]|[^\x00-\x7f\s])*(?=[\s/><])(?!\s*=)") _JSX_EXTENDS = re.compile(r"\s+extends\s+(?![=>])") +_JSX_DIRECT_ARROW_HEAD = re.compile(r"<[\w$\s.,?:|&\[\]{}]+>\s*\([\w$\s.,?:|&\[\]{}]*\)\s*=>") _JSX_CLOSING = re.compile(r"") _CLASS_VALUE_OPERATORS = {":", "=", "+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "&&=", "||=", "??="} _PREFIX = {"keyof", "readonly", "unique", "typeof", "asserts", "abstract", "new"} @@ -158,14 +159,23 @@ def _jsx_type_parameter_head(source: str, index: int) -> bool: opening = _JSX_OPENING.match(source, index) if not opening or not _JSX_EXTENDS.match(source, opening.end()): return False - view = _RuntimeView(source[index : index + 8192], jsx=False) - end = view.angle_end(0) - if end is None or view.value(end) != "(" or end not in view.pairs: - return False - body = view.pairs[end] + 1 - if view.value(body) == ":": - body = view.type_end(body + 1) - return body is not None and view.value(body) in {"=>", "{"} + limit = min(len(source), index + 8192) + # A direct arrow with flat, unquoted tokens has a complete lexical boundary. + # Validate only that header first; complex types and unsuccessful candidates + # retain the original full lookahead and its conservative grammar. + arrow = _JSX_DIRECT_ARROW_HEAD.match(source, index, limit) + stops = (arrow.end(), limit) if arrow and arrow.end() < limit else (limit,) + for stop in stops: + view = _RuntimeView(source[index:stop], jsx=False) + end = view.angle_end(0) + if end is None or view.value(end) != "(" or end not in view.pairs: + continue + body = view.pairs[end] + 1 + if view.value(body) == ":": + body = view.type_end(body + 1) + if body is not None and view.value(body) in {"=>", "{"}: + return True + return False def _jsx_element(source: str, index: int, depth: int) -> _Token | None: diff --git a/tests/test_generic_header_lookahead.py b/tests/test_generic_header_lookahead.py new file mode 100644 index 0000000..61cdc76 --- /dev/null +++ b/tests/test_generic_header_lookahead.py @@ -0,0 +1,98 @@ +"""Complete generic-arrow headers avoid lexing the following file repeatedly.""" + +import time + +import pytest +from click.testing import CliRunner + +from deadcode import _typescript +from deadcode.cli import cli +from deadcode.scanner import DeadCodeScanner + + +@pytest.mark.parametrize("count", [40, 100, 400]) +@pytest.mark.parametrize("constraint", ["unknown", "{}"]) +def test_repeated_short_generic_arrows_have_bounded_parser_input(monkeypatch, count, constraint): + source = 'import styles from "./Card.module.css";\n' + source += "".join(f"const f{i} = (value:T) => value;\n" for i in range(count)) + source += "const view =
;\n" + lengths = [] + original = _typescript._RuntimeView.__init__ + + def measured(self, text, *, jsx=True): + lengths.append(len(text)) + original(self, text, jsx=jsx) + + monkeypatch.setattr(_typescript._RuntimeView, "__init__", measured) + rendered = _typescript.class_usage_source(source, "consumer.tsx") + + assert len(rendered) == len(source) + assert "styles.local" in rendered + assert "extends" not in rendered + assert sum(lengths) <= 32 * len(source) + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + ("(é:T) => é", True), + ("(...values:T[]) => values", True), + ("(value:T) => value", True), + (" */ {}>(value:T) => value", True), + (' ) > <"}>(value:T) => value', True), + ("(value:T): () => {} => () => ({})", True), + ("(value:T): (item:U) => {} => (item:U) => ({})", True), + ( + "(value:T): T extends string ? (() => {className:string,local:string}) " + ": (() => {className:string,local:string}) => () => ({className:'live',local:styles.local});", + False, + ), + ("(value:T) { return value; }", True), + ('', False), + ("(value:T[) => value", False), + ], +) +def test_generic_header_decisions_preserve_complete_and_fallback_boundaries(source, expected): + assert _typescript._jsx_type_parameter_head(source, 0) is expected + + +@pytest.mark.parametrize("arrow_end", [8191, 8192, 8193]) +def test_direct_arrow_respects_the_existing_lookahead_limit(arrow_end): + prefix = "(" + ending = ") =>" + source = prefix + " " * (arrow_end - len(prefix) - len(ending)) + ending + " value;" + assert _typescript._jsx_type_parameter_head(source, 0) is (arrow_end <= 8192) + + +@pytest.mark.parametrize("constraint", ["unknown", "{}", '{className:"legacy"}']) +def test_generic_header_optimization_preserves_live_css_and_removal(tmp_path, monkeypatch, constraint): + monkeypatch.setattr(time, "sleep", lambda _: None) + consumer = tmp_path / "consumer.tsx" + consumer.write_text( + 'import styles from "./Card.module.css";\n' + f"const probe = (value:T) => " + '({className:"live", local:styles.local});\n', + encoding="utf-8", + ) + global_css = tmp_path / "global.css" + module_css = tmp_path / "Card.module.css" + global_css.write_text(".live {}\n.dead {}\n.legacy {}\n", encoding="utf-8") + module_css.write_text(".local {}\n.deadLocal {}\n", encoding="utf-8") + original_source = consumer.read_bytes() + + scan = DeadCodeScanner(tmp_path).scan() + assert not scan.errors + assert sorted((finding.name, finding.removable) for finding in scan.orphaned_css) == [ + ("dead", True), + ("deadLocal", True), + ("legacy", True), + ] + runner = CliRunner() + dry_run = runner.invoke(cli, ["-p", str(tmp_path), "remove", "-c", "orphaned_css", "--dry-run"]) + assert dry_run.exit_code == 0, dry_run.output + assert global_css.read_text(encoding="utf-8") == ".live {}\n.dead {}\n.legacy {}\n" + removed = runner.invoke(cli, ["-p", str(tmp_path), "remove", "-c", "orphaned_css"]) + assert removed.exit_code == 0, removed.output + assert global_css.read_text(encoding="utf-8") == ".live {}\n" + assert module_css.read_text(encoding="utf-8") == ".local {}\n" + assert consumer.read_bytes() == original_source