mirror of
https://github.com/unclebob/dryer.git
synced 2026-11-06 18:33:49 +00:00
Fail closed on git status and walk deep forms without recursion.
Status records are NUL-terminated, and a git failure returns git's status. .test.cts and .spec.cts are tests. Help lists every skipped directory.
This commit is contained in:
+156
-64
@@ -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)]
|
||||
|
||||
+70
-21
@@ -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
|
||||
|
||||
+201
-102
@@ -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`."""
|
||||
|
||||
|
||||
@@ -69,6 +69,8 @@ def is_test_file(path: str | Path) -> bool:
|
||||
".spec.tsx",
|
||||
".test.mts",
|
||||
".spec.mts",
|
||||
".test.cts",
|
||||
".spec.cts",
|
||||
)
|
||||
):
|
||||
return True
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+71
-18
@@ -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:
|
||||
|
||||
+87
-60
@@ -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", "[email protected]"], 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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user