diff --git a/src/dryer/astnorm.py b/src/dryer/astnorm.py index f185a2c..0e58afa 100644 --- a/src/dryer/astnorm.py +++ b/src/dryer/astnorm.py @@ -152,33 +152,7 @@ def _text(node) -> str: return node_text(_DATA, node) -def _normalize(node, head: bool = False): - if node.type in _SKIP: - return None - if node.type in _OPERATORS: - return [K("symbol"), node.type] - if not node.is_named: - return None - if node.type in _IDENTIFIERS: - if head: - return [K("symbol"), _text(node)] - return K("symbol") - if _is_literal(node): - return K("literal") - if node.type == "parenthesized_expression": - named = node.named_children - if len(named) == 1: - return _normalize(named[0], head) - if node.type in _CALLS: - return _normalize_call(node) - if node.type in _ATTRIBUTES: - return _normalize_attr(node, head) - children = [] - for child in node.children: - norm = _normalize(child, False) - if norm is not None: - children.append(norm) - return [K(node.type), *children] +_PENDING = object() def _is_literal(node) -> bool: @@ -204,54 +178,88 @@ def _without_type_args(nodes): return rest, type_args -def _extend_normalized(out, nodes) -> None: - for node in nodes: - norm = _normalize(node, False) - if norm is not None: - out.append(norm) +def _try_leaf(node, mode: str, head: bool): + if mode == "callee": + if node.type in _IDENTIFIERS: + return [K("symbol"), _text(node)] + return _PENDING + if mode == "path": + return _PENDING + if node.type in _SKIP: + return None + if node.type in _OPERATORS: + return [K("symbol"), node.type] + if not node.is_named: + return None + if node.type in _IDENTIFIERS: + if head: + return [K("symbol"), _text(node)] + return K("symbol") + if _is_literal(node): + return K("literal") + return _PENDING -def _normalize_call(node): +def _call_jobs(node): incoming, args = _named_before_args(node) rest, type_args = _without_type_args(incoming) - out = [K(node.type)] + jobs = [] if rest: *receivers, callee = rest - _extend_normalized(out, receivers) - out.append(_normalize_callee(callee)) - _extend_normalized(out, type_args) + jobs.extend((child, "norm", False) for child in receivers) + jobs.append((callee, "callee", False)) + jobs.extend((child, "norm", False) for child in type_args) if args is not None: - _extend_normalized(out, [args]) - return out + jobs.append((args, "norm", False)) + return jobs -def _normalize_callee(node): - if node.type in _IDENTIFIERS: - return [K("symbol"), _text(node)] - if node.type in _ATTRIBUTES: - return _normalize_attr(node, True) - if node.type in _SCOPED or node.type == "generic_type": - return _normalize_path(node) - return _normalize(node, True) +def _attr_jobs(node, head: bool): + named = list(node.named_children) + if not named: + return [] + *objects, name = named + jobs = [(obj, "norm", False) for obj in objects] + if name.type in _IDENTIFIERS: + return jobs + if head: + jobs.append((name, "callee", False)) + else: + jobs.append((name, "norm", False)) + return jobs -def _normalize_path(node): - """A callee path keeps each name in the path. Type arguments stay structural.""" - - parts = [K(node.type)] +def _path_jobs(node): + jobs = [] for child in node.named_children: if child.type in _IDENTIFIERS: - parts.append([K("symbol"), _text(child)]) - elif child.type in _SCOPED or child.type == "generic_type": - parts.append(_normalize_path(child)) + continue + if child.type in _SCOPED or child.type == "generic_type": + jobs.append((child, "path", False)) else: - norm = _normalize(child, False) - if norm is not None: - parts.append(norm) - return parts + jobs.append((child, "norm", False)) + return jobs -def _normalized_name(name, head: bool): +def _jobs(node, mode: str, head: bool): + if mode == "callee": + if node.type in _ATTRIBUTES: + return _attr_jobs(node, True) + if node.type in _SCOPED or node.type == "generic_type": + return _path_jobs(node) + return [(node, "norm", True)] + if mode == "path": + return _path_jobs(node) + if node.type == "parenthesized_expression" and len(node.named_children) == 1: + return [(node.named_children[0], "norm", head)] + if node.type in _CALLS: + return _call_jobs(node) + if node.type in _ATTRIBUTES: + return _attr_jobs(node, head) + return [(child, "norm", False) for child in node.children] + + +def _name_value(name, head: bool, cache): """Keep a member's spelling when it is the thing being called.""" if name.type in _IDENTIFIERS: @@ -259,21 +267,105 @@ def _normalized_name(name, head: bool): return [K("symbol"), _text(name)] return K("symbol") if head: - return _normalize_callee(name) - return _normalize(name, False) + return cache[(id(name), "callee", False)] + return cache[(id(name), "norm", False)] -def _normalize_attr(node, head: bool): +def _assemble_call(node, cache): + incoming, args = _named_before_args(node) + rest, type_args = _without_type_args(incoming) + out = [K(node.type)] + if rest: + *receivers, callee = rest + for child in receivers: + norm = cache[(id(child), "norm", False)] + if norm is not None: + out.append(norm) + out.append(cache[(id(callee), "callee", False)]) + for child in type_args: + norm = cache[(id(child), "norm", False)] + if norm is not None: + out.append(norm) + if args is not None: + norm = cache[(id(args), "norm", False)] + if norm is not None: + out.append(norm) + return out + + +def _assemble_attr(node, head: bool, cache): named = list(node.named_children) if not named: return [K(node.type)] *objects, name = named parts = [K(node.type)] for obj in objects: - norm = _normalize(obj, False) + norm = cache[(id(obj), "norm", False)] if norm is not None: parts.append(norm) - nested = _normalized_name(name, head) + nested = _name_value(name, head, cache) if nested is not None: parts.append(nested) return parts + + +def _assemble_path(node, cache): + """A callee path keeps each name in the path. Type arguments stay structural.""" + + parts = [K(node.type)] + for child in node.named_children: + if child.type in _IDENTIFIERS: + parts.append([K("symbol"), _text(child)]) + elif child.type in _SCOPED or child.type == "generic_type": + parts.append(cache[(id(child), "path", False)]) + else: + norm = cache[(id(child), "norm", False)] + if norm is not None: + parts.append(norm) + return parts + + +def _assemble(node, mode: str, head: bool, cache): + if mode == "callee": + if node.type in _ATTRIBUTES: + return _assemble_attr(node, True, cache) + if node.type in _SCOPED or node.type == "generic_type": + return _assemble_path(node, cache) + return cache[(id(node), "norm", True)] + if mode == "path": + return _assemble_path(node, cache) + if node.type == "parenthesized_expression" and len(node.named_children) == 1: + child = node.named_children[0] + return cache[(id(child), "norm", head)] + if node.type in _CALLS: + return _assemble_call(node, cache) + if node.type in _ATTRIBUTES: + return _assemble_attr(node, head, cache) + children = [] + for child in node.children: + norm = cache[(id(child), "norm", False)] + if norm is not None: + children.append(norm) + return [K(node.type), *children] + + +def _normalize(node, head: bool = False): + # Post-order. A 1,200-deep expression must not raise RecursionError. + cache = {} + stack = [(node, "norm", head, False)] + while stack: + current, mode, is_head, expanded = stack.pop() + key = (id(current), mode, is_head) + if not expanded: + if key in cache: + continue + leaf = _try_leaf(current, mode, is_head) + if leaf is not _PENDING: + cache[key] = leaf + continue + stack.append((current, mode, is_head, True)) + for child, child_mode, child_head in reversed(_jobs(current, mode, is_head)): + stack.append((child, child_mode, child_head, False)) + continue + cache[key] = _assemble(current, mode, is_head, cache) + return cache[(id(node), "norm", head)] diff --git a/src/dryer/cli.py b/src/dryer/cli.py index 4460f0e..288754d 100644 --- a/src/dryer/cli.py +++ b/src/dryer/cli.py @@ -2,16 +2,23 @@ from __future__ import annotations +import os import subprocess import sys from dataclasses import dataclass, field from pathlib import Path -from dryer.discover import is_test_file, iter_source_files, language_of +from dryer.discover import SKIP_DIRS, TEST_DIRS, is_test_file, iter_source_files, language_of from dryer.report import format_text, render_edn, write_metrics from dryer.scan import find_duplicates, scan_files -HELP = """\ + +def _skipped_directory_text() -> str: + ordered = sorted(set(SKIP_DIRS) | set(TEST_DIRS)) + return ", ".join(ordered[:-1]) + ", and " + ordered[-1] + + +HELP = f"""\ Usage: dryer [options] [path-or-filter ...] Find candidate duplicate code in Clojure, Java, Go, TypeScript, Rust, and @@ -48,8 +55,8 @@ Arguments: path contains this text are compared. With no paths, source files under the project root are compared. Directories -named test, tests, spec, specs, vendor, node_modules, and target are skipped, -as are *_test.go, *.spec.ts, test_*.py, and *_test.py files. +named {_skipped_directory_text()} are skipped, as are *_test.go, *.test.cts, +*.spec.cts, *.spec.ts, test_*.py, and *_test.py files. Clojure compares every top-level form except ns. The other languages compare functions and methods. A form is only compared with forms in the same language. @@ -159,25 +166,55 @@ def parse_args(argv: list[str] | None = None) -> Options: return options -def _git_status(root: Path): - args = ["git", "status", "--porcelain"] - return subprocess.run(args, cwd=root, check=False, capture_output=True, text=True) +class GitStatusError(Exception): + """git status could not be read. `code` is git's own status.""" + + def __init__(self, code: int, message: str): + super().__init__(message) + self.code = code + self.message = message + + +def _git(root: Path, *args: str) -> bytes: + result = subprocess.run( + ["git", "-C", os.fspath(root), *args], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode != 0: + message = os.fsdecode(result.stderr).strip() or "git status failed" + raise GitStatusError(result.returncode, message) + return result.stdout def _changed_files(root: Path) -> list[Path]: - result = _git_status(root) - if result.returncode != 0: - print(result.stderr.strip() or "git status failed", file=sys.stderr) - return [] + """Added and modified files under `root`, as paths from the repo root. + + `-z` keeps spaces and non-ASCII names intact. A missing path, including a + deletion, is left out. A rename is the new file only. + """ + + toplevel = Path(os.fsdecode(_git(root, "rev-parse", "--show-toplevel")).strip()) + status = _git( + root, + "status", + "--porcelain", + "-z", + "--no-renames", + "--untracked-files=all", + "--", + ".", + ) + root_resolved = root.resolve() found: list[Path] = [] - for line in result.stdout.splitlines(): - if len(line) < 4: + for entry in status.split(b"\0"): + if len(entry) <= 3: continue - path_text = line[3:].strip() - if " -> " in path_text: - path_text = path_text.split(" -> ", 1)[1] - path_text = path_text.strip('"') - found.append((root / path_text).resolve()) + path = (toplevel / os.fsdecode(entry[3:])).resolve() + if not path.is_file() or not path.is_relative_to(root_resolved): + continue + found.append(path) return found @@ -195,14 +232,22 @@ def _positionals(root: Path, args: list[str]) -> tuple[list[Path], list[str]]: return existing, filters -def _tracked_source(path: Path) -> bool: +def _tracked_source(path: Path, root: Path | None = None) -> bool: + parts = path.parts + if root is not None: + try: + parts = path.resolve().relative_to(root.resolve()).parts + except ValueError: + return False + if not SKIP_DIRS.isdisjoint(parts): + return False if language_of(path) is None: return False return not is_test_file(path) def _changed_source(root: Path) -> list[Path]: - return [path for path in _changed_files(root) if _tracked_source(path)] + return [path for path in _changed_files(root) if _tracked_source(path, root)] def _explicit_files(existing: list[Path]) -> list[Path]: @@ -249,7 +294,11 @@ def run(argv: list[str] | None = None) -> int: return options.exit_code root = options.project_root.resolve() - files = select_files(options) + try: + files = select_files(options) + except GitStatusError as exc: + print(exc.message, file=sys.stderr) + return exc.code if not files: print("No source files to analyze.") return 0 diff --git a/src/dryer/clojure_read.py b/src/dryer/clojure_read.py index 532b19e..67c42af 100644 --- a/src/dryer/clojure_read.py +++ b/src/dryer/clojure_read.py @@ -64,6 +64,20 @@ class Splice: self.items = items +_NEED = object() + + +def _close_coll(kind: str, line: int, items: list, fn_star: bool): + if fn_star: + return Coll("list", line, items=[Sym("fn*", line), *items]) + if kind == "map": + if len(items) % 2: + raise ReadError(line, "map literal has an odd number of forms") + pairs = [(items[index], items[index + 1]) for index in range(0, len(items), 2)] + return Coll("map", line, pairs=pairs) + return Coll(kind, line, items=list(items)) + + class Reader: def __init__(self, text: str): self.text = text @@ -146,79 +160,69 @@ class Reader: return Kw("", line) return Kw(self.read_token(), line) - def read_collection(self, end: str, kind: str) -> Coll: - line = self.line - self.get() - items: list = [] + def read_form(self): + """One form. None and Splice are real results. + + An explicit stack: a 1,200-deep form must not raise RecursionError. + """ + + stack: list[tuple] = [] + produced = _NEED while True: - self.skip_ws() + if produced is _NEED: + produced = self._next(stack) + continue + if not stack: + return produced + produced = self._deliver(stack, produced) + + def _next(self, stack: list[tuple]): + self.skip_ws() + if stack and stack[-1][0] == "coll": + _tag, end, kind, line, items, fn_star = stack[-1] if self.eof(): raise ReadError(line, f"unterminated {kind}") if self.peek() == end: self.get() - break - form = self.read_form() - if form is None: - continue - if isinstance(form, Splice): - items.extend(form.items) - continue - items.append(form) - if kind == "map": - if len(items) % 2: - raise ReadError(line, "map literal has an odd number of forms") - pairs = [(items[i], items[i + 1]) for i in range(0, len(items), 2)] - return Coll("map", line, pairs=pairs) - return Coll(kind, line, items=items) + stack.pop() + return _close_coll(kind, line, items, fn_star) + if self.eof(): + raise ReadError(self.line, "unexpected end of file") + return self._open(stack) - def _wrap(self, name: str, line: int) -> Coll: - inner = self.read_form() - items = [Sym(name, line)] - if inner is not None and not isinstance(inner, Splice): - items.append(inner) - return Coll("list", line, items=items) - - def read_dispatch(self): - line = self.line - self.get() - ch = self.peek() - if ch is None: - raise ReadError(line, "incomplete dispatch") - if ch == "_": - self.get() - self.read_form() + def _deliver(self, stack: list[tuple], produced): + frame = stack[-1] + tag = frame[0] + if tag == "coll": + items = frame[4] + if produced is None: + return _NEED + if isinstance(produced, Splice): + items.extend(produced.items) + else: + items.append(produced) + return _NEED + stack.pop() + if tag == "wrap": + _tag, name, line = frame + items = [Sym(name, line)] + if produced is not None and not isinstance(produced, Splice): + items.append(produced) + return Coll("list", line, items=items) + if tag == "discard": return None - if ch == "{": - return self.read_collection("}", "set") - if ch == "(": - inner = self.read_collection(")", "list") - return Coll("list", line, items=[Sym("fn*", line), *inner.items]) - if ch == '"': - self.read_string() - return Lit(line) - if ch == "?": - self.get() - splicing = False - if self.peek() == "@": - self.get() - splicing = True - return self.read_cond(splicing) - if ch == ":": - self.get() - if self.peek() == ":": - self.get() - elif self.peek() not in (None, "{") and self.peek() not in _WHITESPACE: - self.read_token() - return self.read_form() - self.read_token() - self.read_form() - return Lit(line) + if tag == "meta": + return _NEED + if tag == "lit": + return Lit(frame[1]) + if tag == "cond": + return self._finish_cond(frame[1], produced) + raise AssertionError(tag) - def read_cond(self, splicing: bool): - form = self.read_form() - if not isinstance(form, Coll) or form.kind != "list": + def _finish_cond(self, splicing: bool, produced): + if not isinstance(produced, Coll) or produced.kind != "list": raise ReadError(self.line, "reader conditional body must be a list") - chosen = _chosen_branch(form.items) + chosen = _chosen_branch(produced.items) if chosen is None: return None if not splicing: @@ -228,18 +232,21 @@ class Reader: raise ReadError(self.line, "splicing reader conditional needs a collection") return spliced - def read_form(self): - self.skip_ws() - if self.eof(): - raise ReadError(self.line, "unexpected end of file") + def _open(self, stack: list[tuple]): ch = self.peek() line = self.line if ch == "(": - return self.read_collection(")", "list") + self.get() + stack.append(("coll", ")", "list", line, [], False)) + return _NEED if ch == "[": - return self.read_collection("]", "vector") + self.get() + stack.append(("coll", "]", "vector", line, [], False)) + return _NEED if ch == "{": - return self.read_collection("}", "map") + self.get() + stack.append(("coll", "}", "map", line, [], False)) + return _NEED if ch == '"': return self.read_string() if ch == "\\": @@ -248,31 +255,75 @@ class Reader: return self.read_keyword() if ch == "'": self.get() - return self._wrap("quote", line) + stack.append(("wrap", "quote", line)) + return _NEED if ch == "@": self.get() - return self._wrap("deref", line) + stack.append(("wrap", "deref", line)) + return _NEED if ch == "`": self.get() - return self._wrap("syntax-quote", line) + stack.append(("wrap", "syntax-quote", line)) + return _NEED if ch == "~": self.get() name = "unquote" if self.peek() == "@": self.get() name = "unquote-splicing" - return self._wrap(name, line) + stack.append(("wrap", name, line)) + return _NEED if ch == "^": self.get() - self.read_form() - return self.read_form() + stack.append(("meta",)) + return _NEED if ch == "#": - return self.read_dispatch() + return self._dispatch(stack, line) token = self.read_token() if _NUMBER.match(token): return Lit(line) return Sym(token, line) + def _dispatch(self, stack: list[tuple], line: int): + self.get() + ch = self.peek() + if ch is None: + raise ReadError(line, "incomplete dispatch") + if ch == "_": + self.get() + stack.append(("discard",)) + return _NEED + if ch == "{": + brace = self.line + self.get() + stack.append(("coll", "}", "set", brace, [], False)) + return _NEED + if ch == "(": + self.get() + stack.append(("coll", ")", "list", line, [], True)) + return _NEED + if ch == '"': + self.read_string() + return Lit(line) + if ch == "?": + self.get() + splicing = False + if self.peek() == "@": + self.get() + splicing = True + stack.append(("cond", splicing)) + return _NEED + if ch == ":": + self.get() + if self.peek() == ":": + self.get() + elif self.peek() not in (None, "{") and self.peek() not in _WHITESPACE: + self.read_token() + return _NEED + self.read_token() + stack.append(("lit", line)) + return _NEED + def _branch_name(feature): if isinstance(feature, Kw): @@ -341,33 +392,18 @@ def _children(form): def max_line(form) -> int: best = getattr(form, "line", 1) or 1 - for child in _children(form): - if child is not None: - best = max(best, max_line(child)) + stack = [form] + while stack: + current = stack.pop() + if current is None: + continue + best = max(best, getattr(current, "line", 1) or 1) + if isinstance(current, Coll): + stack.extend(_children(current)) return best -def _normalize_list(form): - if not form.items: - return [K("list"), K("literal")] - head_form, *args = form.items - return [K("list"), normalize(head_form, True), *[normalize(arg, False) for arg in args]] - - -def normalize(form, head: bool = False): - """dry4clj's `normalize-form`. Collection heads are normalized in full.""" - - if isinstance(form, Coll): - if form.kind == "list": - return _normalize_list(form) - if form.kind == "vector": - return [K("vector"), *[normalize(item, False) for item in form.items]] - if form.kind == "set": - return [K("set"), *[normalize(item, False) for item in form.items]] - if form.kind == "map": - pairs = [[normalize(key, False), normalize(value, False)] for key, value in form.pairs] - return [K("map"), *pairs] - return [K("literal")] +def _normalize_atom(form, head: bool): if isinstance(form, Sym): if head: return [K("symbol"), form.name] @@ -377,6 +413,69 @@ def normalize(form, head: bool = False): return K("literal") +def _norm_children(form) -> list[tuple[object, bool]]: + if form.kind == "list": + if not form.items: + return [] + head_form, *args = form.items + return [(head_form, True), *[(arg, False) for arg in args]] + if form.kind in {"vector", "set"}: + return [(item, False) for item in form.items] + if form.kind == "map": + children = [] + for key, value in form.pairs: + children.append((key, False)) + children.append((value, False)) + return children + return [] + + +def _assemble_form(form, cache): + if form.kind == "list": + if not form.items: + return [K("list"), K("literal")] + head_form, *args = form.items + return [ + K("list"), + cache[(id(head_form), True)], + *[cache[(id(arg), False)] for arg in args], + ] + if form.kind == "vector": + return [K("vector"), *[cache[(id(item), False)] for item in form.items]] + if form.kind == "set": + return [K("set"), *[cache[(id(item), False)] for item in form.items]] + if form.kind == "map": + pairs = [ + [cache[(id(key), False)], cache[(id(value), False)]] for key, value in form.pairs + ] + return [K("map"), *pairs] + return [K("literal")] + + +def normalize(form, head: bool = False): + """dry4clj's `normalize-form`. Collection heads are normalized in full.""" + + if not isinstance(form, Coll): + return _normalize_atom(form, head) + cache = {} + stack: list[tuple] = [(form, head, False)] + while stack: + current, is_head, expanded = stack.pop() + key = (id(current), is_head) + if not expanded: + if key in cache: + continue + if not isinstance(current, Coll): + cache[key] = _normalize_atom(current, is_head) + continue + stack.append((current, is_head, True)) + for child, child_head in reversed(_norm_children(current)): + stack.append((child, child_head, False)) + continue + cache[key] = _assemble_form(current, cache) + return cache[(id(form), head)] + + def is_candidate_form(form) -> bool: """A non-empty top-level list whose head is not `ns`.""" diff --git a/src/dryer/discover.py b/src/dryer/discover.py index 6004797..6feeaa7 100644 --- a/src/dryer/discover.py +++ b/src/dryer/discover.py @@ -69,6 +69,8 @@ def is_test_file(path: str | Path) -> bool: ".spec.tsx", ".test.mts", ".spec.mts", + ".test.cts", + ".spec.cts", ) ): return True diff --git a/src/dryer/extract.py b/src/dryer/extract.py index 66a6953..32b07fa 100644 --- a/src/dryer/extract.py +++ b/src/dryer/extract.py @@ -73,8 +73,9 @@ def _rust_in_tests(data: bytes, node) -> bool: def _java_nodes(data: bytes, root) -> list: found = [] - - def visit(node) -> None: + stack = [root] + while stack: + node = stack.pop() if node.type == "method_declaration": nested = _inside( node, @@ -82,11 +83,8 @@ def _java_nodes(data: bytes, root) -> list: ) if _has(node, "block") and not nested: found.append(node) - return - for child in node.children: - visit(child) - - visit(root) + continue + stack.extend(reversed(node.children)) return found diff --git a/src/dryer/shape.py b/src/dryer/shape.py index 0ab0c1d..728f3a3 100644 --- a/src/dryer/shape.py +++ b/src/dryer/shape.py @@ -18,38 +18,91 @@ class K: return ":" + self.name -def pr(node) -> str: - """Print a normalized node the way Clojure `pr-str` prints it.""" - +def _pr_atom(node) -> str: if isinstance(node, K): return ":" + node.name if isinstance(node, str): escaped = node.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"' - if isinstance(node, list): - return "[" + " ".join(pr(child) for child in node) + "]" raise TypeError(f"cannot print {node!r}") +def pr(node) -> str: + """Print a normalized node the way Clojure `pr-str` prints it.""" + + if not isinstance(node, list): + return _pr_atom(node) + # Post-order. A 1,200-deep tree must not raise RecursionError. + done: dict[int, str] = {} + stack: list[tuple[list, bool]] = [(node, False)] + while stack: + current, expanded = stack.pop() + key = id(current) + if expanded: + pieces = [] + for child in current: + if isinstance(child, list): + pieces.append(done[id(child)]) + else: + pieces.append(_pr_atom(child)) + done[key] = "[" + " ".join(pieces) + "]" + continue + if key in done: + continue + stack.append((current, True)) + for child in reversed(current): + if isinstance(child, list): + stack.append((child, False)) + return done[id(node)] + + def node_count(node) -> int: """One plus the count of every nested node. Atoms count as one.""" - if isinstance(node, list): - return 1 + sum(node_count(child) for child in node) - return 1 + if not isinstance(node, list): + return 1 + total = 0 + stack: list = [node] + while stack: + current = stack.pop() + total += 1 + if isinstance(current, list): + stack.extend(current) + return total def fingerprints(node) -> frozenset[str]: - found: set[str] = set() - - def walk(form) -> None: - found.add(pr(form)) - if isinstance(form, list): - for child in form: - walk(child) - - walk(node) - return frozenset(found) + if not isinstance(node, list): + return frozenset({pr(node)}) + done: dict[int, str] = {} + stack: list[tuple[list, bool]] = [(node, False)] + while stack: + current, expanded = stack.pop() + key = id(current) + if expanded: + pieces = [] + for child in current: + if isinstance(child, list): + pieces.append(done[id(child)]) + else: + pieces.append(_pr_atom(child)) + done[key] = "[" + " ".join(pieces) + "]" + continue + if key in done: + continue + stack.append((current, True)) + for child in reversed(current): + if isinstance(child, list): + stack.append((child, False)) + atoms: set[str] = set() + pending: list = [node] + while pending: + current = pending.pop() + if isinstance(current, list): + pending.extend(current) + else: + atoms.add(_pr_atom(current)) + return frozenset(set(done.values()) | atoms) def jaccard(left: frozenset[str], right: frozenset[str]) -> float: diff --git a/tests/test_cli.py b/tests/test_cli.py index fd2f8e5..7a6875d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,8 +1,18 @@ +import subprocess from pathlib import Path import pytest -from dryer.cli import _changed_files, _count, _tracked_source, main, parse_args, run, select_files +from dryer.cli import ( + GitStatusError, + _changed_files, + _count, + _tracked_source, + main, + parse_args, + run, + select_files, +) from dryer.discover import is_test_file, iter_source_files, language_of @@ -28,6 +38,8 @@ def test_help_does_not_scan(capsys): assert run(["--help"]) == 0 out = capsys.readouterr().out assert "Usage: dryer" in out + assert "__pycache__" in out + assert ".test.cts" in out assert "--threshold" in out @@ -153,6 +165,8 @@ def test_names_that_are_tests(): "ui/a.spec.tsx", "ui/a.test.mts", "ui/a.spec.mts", + "ui/a.test.cts", + "ui/a.spec.cts", "tests/conftest.py", "pkg/foo_test.py", "pkg/test_foo.py", @@ -163,54 +177,71 @@ def test_names_that_are_tests(): assert not is_test_file("src/app.py") -class _Git: - def __init__(self, returncode, stdout, stderr=""): - self.returncode = returncode - self.stdout = stdout - self.stderr = stderr +def _init_repo(path: Path) -> None: + subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True) + subprocess.run(["git", "config", "user.email", "dev@example.com"], cwd=path, check=True) + subprocess.run(["git", "config", "user.name", "Dev"], cwd=path, check=True) -def test_changed_files_reads_porcelain(tmp_path, monkeypatch): - stdout = ' M src/a.py\nR old.py -> src/b.py\n?? "src/c d.py"\n\nX\n' - monkeypatch.setattr( - "dryer.cli.subprocess.run", - lambda *args, **kwargs: _Git(0, stdout), - ) - assert _changed_files(tmp_path) == [ - (tmp_path / "src/a.py").resolve(), - (tmp_path / "src/b.py").resolve(), - (tmp_path / "src/c d.py").resolve(), - ] +def _commit(path: Path, message: str) -> None: + subprocess.run(["git", "add", "-A"], cwd=path, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", message], cwd=path, check=True, capture_output=True) -def test_changed_files_reports_git_failure(tmp_path, monkeypatch, capsys): - monkeypatch.setattr( - "dryer.cli.subprocess.run", - lambda *args, **kwargs: _Git(1, "", "fatal: not a git repository\n"), - ) - assert _changed_files(tmp_path) == [] - assert capsys.readouterr().err == "fatal: not a git repository\n" - - monkeypatch.setattr( - "dryer.cli.subprocess.run", - lambda *args, **kwargs: _Git(1, "", " \n"), - ) - assert _changed_files(tmp_path) == [] - assert capsys.readouterr().err == "git status failed\n" +def test_changed_files_reads_real_status(tmp_path): + _init_repo(tmp_path) + write_source(tmp_path, "src/a.py", "x = 1\n") + write_source(tmp_path, "src/old.py", "x = 1\n") + write_source(tmp_path, "gone.py", "x = 1\n") + _commit(tmp_path, "base") + (tmp_path / "gone.py").unlink() + subprocess.run(["git", "mv", "src/old.py", "src/b.py"], cwd=tmp_path, check=True, capture_output=True) + write_source(tmp_path, "src/c d.py", "x = 1\n") + write_source(tmp_path, "src/café.py", "x = 1\n") + write_source(tmp_path, "fresh/nested/new.py", "x = 1\n") + found = set(_changed_files(tmp_path)) + assert (tmp_path / "src/b.py").resolve() in found + assert (tmp_path / "src/c d.py").resolve() in found + assert (tmp_path / "src/café.py").resolve() in found + assert (tmp_path / "fresh/nested/new.py").resolve() in found + assert (tmp_path / "gone.py").resolve() not in found + assert (tmp_path / "src/old.py").resolve() not in found -def test_changed_limits_the_report(tmp_path, monkeypatch, capsys): +def test_changed_files_reports_git_failure(tmp_path, capsys): + with pytest.raises(GitStatusError) as caught: + _changed_files(tmp_path) + assert caught.value.code == 128 + assert "not a git repository" in caught.value.message + assert capsys.readouterr().err == "" + + code = run(["--changed", "--root", str(tmp_path)]) + captured = capsys.readouterr() + assert code == 128 + assert "not a git repository" in captured.err + assert "No source files" not in captured.out + + +def test_an_empty_git_error_uses_the_fallback_text(monkeypatch, tmp_path): + def fail(args, **kwargs): + return subprocess.CompletedProcess(args, 128, b"", b" \n") + + monkeypatch.setattr("dryer.cli.subprocess.run", fail) + with pytest.raises(GitStatusError, match="git status failed") as caught: + _changed_files(tmp_path) + assert caught.value.code == 128 + + +def test_changed_limits_the_report(tmp_path, capsys): body = "def alpha(xs):\n ys = filter(xs, odd)\n zs = map(ys, inc)\n return list(zs)\n" other = "def beta(items):\n kept = filter(items, even)\n out = map(kept, dec)\n return list(out)\n" + _init_repo(tmp_path) + write_source(tmp_path, "src/c.py", body) + _commit(tmp_path, "base") write_source(tmp_path, "src/a.py", body) write_source(tmp_path, "src/b.py", other) - write_source(tmp_path, "src/c.py", body) write_source(tmp_path, "tests/test_a.py", other) - stdout = " M src/a.py\n M src/b.py\n M tests/test_a.py\n ?? README.md\n" - monkeypatch.setattr( - "dryer.cli.subprocess.run", - lambda *args, **kwargs: _Git(0, stdout), - ) + write_source(tmp_path, "README.md", "notes\n") assert run(["--root", str(tmp_path), "--changed", "--min-lines", "3", "--min-nodes", "1"]) == 0 out = capsys.readouterr().out assert "src/a.py" in out @@ -275,35 +306,28 @@ def test_source_root_limits_the_walk(tmp_path): assert files == ["src/a.py"] -def test_git_status_asks_for_text_without_checking(tmp_path, monkeypatch): - seen = {} - - def fake_run(args, **kwargs): - seen["args"] = args - seen["kwargs"] = kwargs - return _Git(0, " M src/a.py\n") - - monkeypatch.setattr("dryer.cli.subprocess.run", fake_run) - assert _changed_files(tmp_path) == [(tmp_path / "src/a.py").resolve()] - assert seen["args"] == ["git", "status", "--porcelain"] - assert seen["kwargs"]["check"] is False - assert seen["kwargs"]["capture_output"] is True - assert seen["kwargs"]["text"] is True - assert seen["kwargs"]["cwd"] == tmp_path +def test_changed_from_a_subdirectory_stays_inside_it(tmp_path): + repo = tmp_path / "repo" + sub = repo / "sub" + repo.mkdir() + _init_repo(repo) + write_source(repo, "src/above.py", "x = 1\n") + write_source(sub, "src/below.py", "x = 1\n") + chosen = select_files(parse_args(["--root", str(sub), "--changed"])) + assert chosen == [(sub / "src/below.py").resolve()] -def test_a_status_line_of_four_characters_is_a_path(tmp_path, monkeypatch): - monkeypatch.setattr( - "dryer.cli.subprocess.run", - lambda *args, **kwargs: _Git(0, " M a\n"), - ) - assert _changed_files(tmp_path) == [(tmp_path / "a").resolve()] +def test_a_short_untracked_name_is_a_file(tmp_path): + _init_repo(tmp_path) + write_source(tmp_path, "a.py", "x = 1\n") + assert _changed_files(tmp_path) == [(tmp_path / "a.py").resolve()] def test_changed_source_skips_unknown_and_test_files(tmp_path): assert _tracked_source(tmp_path / "README.md") is False assert _tracked_source(tmp_path / "tests" / "test_a.py") is False assert _tracked_source(tmp_path / "src" / "a.py") is True + assert _tracked_source(tmp_path / "target" / "app.py") is False def test_test_files_and_skipped_directories_are_left_out(tmp_path): @@ -311,8 +335,11 @@ def test_test_files_and_skipped_directories_are_left_out(tmp_path): write_source(tmp_path, "src/foo_test.py", "x = 1\n") write_source(tmp_path, "target/app.py", "x = 1\n") write_source(tmp_path, "tests/app.py", "x = 1\n") + write_source(tmp_path, "src/app.test.cts", "export {}\n") + write_source(tmp_path, "src/app.spec.cts", "export {}\n") + write_source(tmp_path, "src/widget.cts", "export {}\n") relative = [path.relative_to(tmp_path).as_posix() for path in iter_source_files([tmp_path])] - assert relative == ["src/app.py"] + assert sorted(relative) == ["src/app.py", "src/widget.cts"] def test_a_second_report_replaces_the_snapshot(tmp_path, capsys): diff --git a/tests/test_languages.py b/tests/test_languages.py index 6e1bcf5..1710cf8 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -599,3 +599,14 @@ def test_keep_uses_the_line_and_node_minimums(): assert _keep(_entry("a.py", 1, 4, {"q"}, nodes=20), 4, 20) is True assert _keep(_entry("a.py", 1, 3, {"q"}, nodes=20), 4, 20) is False assert _keep(_entry("a.py", 1, 4, {"q"}, nodes=19), 4, 20) is False + + +def test_a_deep_expression_is_read(): + expr = " + ".join(["1"] * 1200) + entries, warning = entries_in_source("python", f"def place():\n return {expr}\n", "app.py", "app.py") + assert warning is None + assert entries + nested = "(inc " * 1200 + "x" + ")" * 1200 + entries, warning = entries_in_source("clojure", f"(defn place [] {nested})\n", "app.clj", "app.clj") + assert warning is None + assert entries