diff --git a/Cargo.lock b/Cargo.lock index bd234b8..dd77fcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -240,6 +240,7 @@ dependencies = [ "serde_json", "tempfile", "tree-sitter", + "tree-sitter-python", "tree-sitter-rust", ] @@ -575,6 +576,16 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-rust" version = "0.24.0" diff --git a/Cargo.toml b/Cargo.toml index 111c0c6..d45ae73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ clap = { version = "4.5", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tree-sitter = "0.25" +tree-sitter-python = "0.25" tree-sitter-rust = "0.24" [dev-dependencies] diff --git a/README.md b/README.md index ac7734d..7a110e9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ - untracked files included in default stats - language filtering with `--lang` -- Rust-only, non-test-only stats by default, with `--test`, `--no-test`, and `--no-test-filter` +- non-test-only stats by default across all supported languages, with `--test`, `--non-test`, and `--no-test-filter` +- test-aware filtering for Rust, Python, and JS/TS families - single-commit and revision-range support This repository also ships `rust-test-audit`, a companion CLI for auditing Rust source trees @@ -73,21 +74,25 @@ git diff-stat --commit HEAD git diff-stat --last git diff-stat --last --no-test-filter git diff-stat HEAD~1..HEAD --lang py --no-test-filter +git diff-stat --lang py --test +git diff-stat --lang tsx --test git diff-stat --test ``` ## Usage ```bash -git diff-stat [ | | ] [--lang rs,js] [--test | --no-test | --no-test-filter] +git diff-stat [ | | ] [--lang rs,py,js,ts,jsx,tsx,cjs,mjs] [--test | --non-test | --no-test-filter] ``` Defaults: -- `--lang` defaults to `rs` -- test filtering defaults to `--no-test` +- `--lang` defaults to all supported languages: `rs,py,js,ts,jsx,tsx,cjs,mjs` +- test filtering defaults to `--non-test` - output always begins with a header line describing the comparison scope, languages, and test scope +That means plain `git diff-stat` already reports non-test changes across all currently supported languages. + ## Rust Test Audit ```bash @@ -125,9 +130,11 @@ test regions cross configurable density thresholds. ## Notes - `--lang` currently uses file extensions. -- `--test` and `--no-test` treat Rust files under `tests/` and Rust files imported by `#[cfg(test)]` module declarations as whole-file test code. Other Rust files still use code-region splitting for `#[cfg(test)]` modules and test-annotated functions such as `#[test]` and `#[tokio::test]`. -- `--no-test-filter` disables Rust test splitting entirely and reports full-file stats for the selected languages. -- because `--lang` defaults to `rs`, use `--no-test-filter --lang ` when you want non-Rust output. +- `--test` and `--non-test` treat Rust files under `tests/` and Rust files imported by `#[cfg(test)]` module declarations as whole-file test code. Other Rust files still use code-region splitting for `#[cfg(test)]` modules and test-annotated functions such as `#[test]` and `#[tokio::test]`. +- `--test` and `--non-test` treat Python files under `tests/`, `test_*.py`, `*_test.py`, and `conftest.py` as whole-file test code. Other Python files split test regions using `def test_*` and `class Test*`. +- `--test` and `--non-test` treat JS/TS family files under `__tests__/`, `e2e/`, `cypress/`, and `playwright/`, plus files matching `*.test.*`, `*.spec.*`, and `*.cy.*`, as whole-file test code. +- `--no-test-filter` disables Rust and Python region splitting and reports full-file stats for the selected languages. +- `--lang` defaults to all supported languages, so use `--lang rs`, `--lang py`, or `--lang tsx` when you want a narrower language set. - `--last` is sugar for the patch introduced by `HEAD`, equivalent to `HEAD^!`. -- rendered output starts with a Chinese description line such as `未提交的 rs 文件中,非测试代码统计如下:`. +- rendered output starts with an English description line such as `Non-test code stats for rs,py,js,ts,jsx,tsx,cjs,mjs files in the working tree:`. - Output is intentionally close to `git diff --stat`, but not byte-for-byte identical. diff --git a/docs/plans/2026-03-21-js-ts-family-support-design.md b/docs/plans/2026-03-21-js-ts-family-support-design.md new file mode 100644 index 0000000..4a37cdc --- /dev/null +++ b/docs/plans/2026-03-21-js-ts-family-support-design.md @@ -0,0 +1,274 @@ +# JS/TS Family Language Support Design + +**Context** + +`git-diff-stat` currently supports Rust and Python as first-class test-aware languages. The language layer in [`src/lang/mod.rs`](../../src/lang/mod.rs) still has two structural limits: + +- the default `--lang` behavior is represented as a hard-coded CLI string instead of "all supported languages" +- JS and TS are only partially recognized as file extensions, and they do not participate in `--test` or `--no-test` + +The next goal is broader frontend language coverage: + +- support `js`, `ts`, `jsx`, `tsx`, `cjs`, and `mjs` +- treat unit tests and e2e tests as test code +- change default `--lang` semantics from a fixed subset to "all supported languages" + +The user explicitly approved a narrow first version for JS/TS test semantics: + +- whole-file test classification only +- no file-internal `describe` / `it` / `test` region splitting + +**Goal** + +Add first-class JS/TS family support with test-aware filtering, while making default language selection come from the language registry rather than from a duplicated string literal in the CLI layer. + +The result should make future language additions easier, not harder. + +**Approaches** + +1. Minimal patching + - Add more extensions directly in [`src/lang/mod.rs`](../../src/lang/mod.rs) + - Change the CLI default string from `rs,py` to a longer comma-separated list + - Add ad hoc JS/TS branches in [`src/test_filter.rs`](../../src/test_filter.rs) + - Rejected because the default language list would still be duplicated across language detection, CLI help, README, and tests. + +2. Registry-driven defaults with lightweight JS/TS backend support + - Introduce a single source of truth for supported languages + - Derive default `--lang` behavior from that registry + - Add a JS/TS backend that performs whole-file test classification only + - Recommended. + +3. Full backend capability framework + - Build a more generic trait/capability model for path matching, whole-file classification, region splitting, aliasing, and default inclusion + - Technically clean, but over-designed for the current repository size + - Rejected for now. + +**Decision** + +Use registry-driven defaults plus a lightweight JS/TS backend. + +This keeps the current Rust/Python design direction, but tightens two pieces that are now becoming important: + +- "supported languages" must live in one place +- test-aware orchestration must support languages that only provide whole-file test classification + +**Default Language Semantics** + +`--lang` should no longer default to a hard-coded subset such as `rs,py`. + +Instead: + +- if the user passes `--lang`, respect exactly that explicit set +- if the user omits `--lang`, treat it as "all supported languages" + +For the current repository state after this change, "all supported languages" means: + +- `rs` +- `py` +- `js` +- `ts` +- `jsx` +- `tsx` +- `cjs` +- `mjs` + +This should be surfaced consistently in: + +- CLI parsing +- output headers +- help text examples +- README defaults +- tests + +The critical rule is that the support list should be declared once in the language layer and reused everywhere else. + +**Proposed Structure** + +- `src/lang/mod.rs` + - registry of supported language tokens + - parsing for explicit `--lang` values + - default-language expansion when `--lang` is omitted + - path-to-language detection +- `src/lang/rust.rs` + - existing Rust support + - whole-file test classification plus region splitting +- `src/lang/python.rs` + - existing Python support + - whole-file test classification plus region splitting +- `src/lang/javascript.rs` + - JS/TS family path matching + - whole-file test classification only +- `src/test_filter.rs` + - shared orchestration across selected languages + - support for backends that only classify whole-file test paths + +This is still a moderate refactor, not a rewrite. + +**Language Registry Shape** + +The registry only needs to answer a few central questions: + +- which language tokens are supported? +- which token matches a given path? +- what is the default language set when `--lang` is omitted? + +One practical model is: + +- `supported_langs() -> &'static [&'static str]` +- `default_langs() -> &'static [&'static str]` +- `parse_langs(value: Option<&str>) -> Vec<&str>` +- `detect_language(path: &str) -> Option<&'static str>` + +For now, `default_langs()` can simply return the same list as `supported_langs()`. + +This avoids duplicating the support list in [`src/cli.rs`](../../src/cli.rs) and [`README.md`](../../README.md). + +**JS/TS Family Matching** + +The new frontend backend should recognize these extensions directly: + +- `.js` +- `.ts` +- `.jsx` +- `.tsx` +- `.cjs` +- `.mjs` + +Each extension should map to its own `--lang` token. This keeps filtering precise: + +- `--lang js` should not automatically include `ts` +- `--lang tsx` should only include `.tsx` +- omitting `--lang` includes all of them + +This is a better fit for current CLI semantics than collapsing everything into a single `web` alias. + +**JS/TS Test Semantics** + +The approved first version is whole-file classification only. + +Treat these as test files: + +- any file under a `__tests__/` path component +- filenames matching `*.test.` +- filenames matching `*.spec.` +- any file under an `e2e/` path component +- any file under a `cypress/` path component +- any file under a `playwright/` path component +- filenames matching `*.cy.` + +Where `` is one of: + +- `js` +- `ts` +- `jsx` +- `tsx` +- `cjs` +- `mjs` + +These rules intentionally cover both unit and e2e test conventions. + +**Out of Scope** + +Not in scope for the first JS/TS version: + +- file-internal test block detection using `describe`, `it`, `test`, or `suite` +- `vitest` inline test detection such as `import.meta.vitest` +- framework-specific config discovery from Jest, Vitest, Playwright, Cypress, or custom tooling +- special handling for snapshot files + +This is intentional. Most real-world JS/TS repositories still place tests in dedicated files or directories, so whole-file classification captures the highest-value cases with low false-positive risk. + +**Test-Filter Orchestration** + +The shared builder in [`src/test_filter.rs`](../../src/test_filter.rs) currently assumes that selected languages either: + +- have whole-file test paths and region splitting, or +- are ignored entirely + +JS/TS adds a third useful case: + +- whole-file test classification only + +The orchestration should therefore support: + +1. languages with whole-file and region split support +2. languages with whole-file-only support + +For JS/TS family files: + +- if a file matches a whole-file test rule, count it as test code +- otherwise, count the full file diff as non-test code + +That preserves correct semantics for `--test`, `--no-test`, and `--no-test-filter` without introducing AST parsing. + +**Source Loading Strategy** + +This addition makes source-loading efficiency more important. + +Rust still needs source contents for path-imported `#[cfg(test)]` module detection. Python and JS/TS whole-file classification are path-driven. The design should avoid eager content reads for languages that only need paths. + +That means the shared builder should distinguish between: + +- path-only whole-file classification +- source-assisted whole-file classification +- region splitting + +Even if the implementation stays simple, it should at least avoid bulk-reading JS/TS files just to classify them by filename or directory name. + +**Data Flow** + +After the refactor, runtime behavior should look like this: + +1. Parse CLI. +2. Resolve revision selection. +3. Parse explicit `--lang`, or expand to all supported languages if omitted. +4. Filter `FileChange` values to the selected languages. +5. If `--no-test-filter`, render full-file stats directly. +6. Otherwise: + - compute whole-file test paths for each selected language backend + - use region splitting only for languages that implement it + - treat JS/TS non-test files as full-file non-test diffs +7. Render the existing header with the updated language scope. + +The biggest behavioral change is that plain `git diff-stat` now means "all supported languages" instead of "Rust and Python only". + +**Testing Strategy** + +Add coverage at three levels: + +1. Registry and extension tests + - supported language parsing + - default language expansion + - path detection for `js`, `ts`, `jsx`, `tsx`, `cjs`, `mjs` +2. JS/TS test classification unit tests + - `__tests__/` + - `*.test.*` + - `*.spec.*` + - `e2e/` + - `cypress/` + - `playwright/` + - `*.cy.*` +3. CLI smoke tests + - default run includes supported frontend files + - default `--no-test` excludes JS/TS unit and e2e test files + - `--test` includes those files + - `--no-test-filter` restores full-file counting + - explicit `--lang tsx` or `--lang cjs` behaves narrowly + +The smoke suite should also prove that mixed repositories still combine Rust, Python, and JS/TS families correctly. + +**Risks** + +- If default-language logic remains duplicated, future additions will drift again between CLI, README, and actual behavior. +- If JS/TS whole-file rules are too broad, application code under directories like `tests-data/` or `playwright.config.ts` could be misclassified; the patterns should stay intentionally narrow and component-based. +- If the builder eagerly reads all JS/TS sources, repositories with many frontend assets could take an unnecessary performance hit. + +**Outcome** + +After this change: + +1. plain `git diff-stat` covers all supported languages +2. JS/TS family files participate in `--test` and `--no-test` +3. test-aware orchestration no longer assumes every language must support region splitting + +That is enough structure to add more path-driven languages later without reworking the CLI defaults again. diff --git a/docs/plans/2026-03-21-js-ts-family-support.md b/docs/plans/2026-03-21-js-ts-family-support.md new file mode 100644 index 0000000..c4482b7 --- /dev/null +++ b/docs/plans/2026-03-21-js-ts-family-support.md @@ -0,0 +1,311 @@ +# JS/TS Family Language Support Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add `js`, `ts`, `jsx`, `tsx`, `cjs`, and `mjs` as first-class `--lang` values, make omitted `--lang` mean "all supported languages", and extend `--test` / `--no-test` to JS/TS unit and e2e test files. + +**Architecture:** Centralize supported/default languages in `src/lang/mod.rs`, add a `src/lang/javascript.rs` backend for whole-file test classification, and update `src/test_filter.rs` to support languages that only provide whole-file test semantics. Preserve Rust and Python behavior while broadening default language coverage. + +**Tech Stack:** Rust, clap, assert_cmd, predicates, cargo test, cargo clippy + +--- + +### Task 1: Lock down registry-driven default language behavior + +**Files:** +- Modify: `tests/lang_filter.rs` +- Modify: `tests/cli_smoke.rs` +- Modify: `src/lang/mod.rs` +- Modify: `src/cli.rs` + +**Step 1: Write the failing test** + +Add unit coverage proving that omitted `--lang` expands to all supported languages instead of a fixed `rs,py` subset. Add one CLI smoke regression proving plain `git diff-stat` includes a frontend source file in addition to Rust/Python when that file has non-test changes. + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test lang_filter --test cli_smoke -v +``` + +Expected: FAIL because the CLI and language parser still default to `rs,py`. + +**Step 3: Write minimal implementation** + +Move default-language responsibility into `src/lang/mod.rs`. Remove the hard-coded CLI default value and make omitted `--lang` resolve to all supported tokens from the registry. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test --test lang_filter --test cli_smoke -v +``` + +Expected: PASS + +### Task 2: Add full JS/TS family extension support + +**Files:** +- Modify: `src/lang/mod.rs` +- Create: `src/lang/javascript.rs` +- Modify: `src/lib.rs` +- Modify: `tests/lang_filter.rs` + +**Step 1: Write the failing test** + +Add unit tests proving language detection and filtering recognize: + +- `app.js` +- `app.ts` +- `component.jsx` +- `component.tsx` +- `config.cjs` +- `entry.mjs` + +Also add a regression proving explicit `--lang tsx` only keeps `.tsx` files. + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test lang_filter -v +``` + +Expected: FAIL because the new extensions are not fully supported yet. + +**Step 3: Write minimal implementation** + +Add `src/lang/javascript.rs` and route JS/TS family extension detection through it. Keep the token model explicit: each extension remains its own `--lang` value rather than collapsing into one alias. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test --test lang_filter -v +``` + +Expected: PASS + +### Task 3: Add JS/TS whole-file test classification primitives + +**Files:** +- Create: `tests/javascript_tests.rs` +- Modify: `src/lang/javascript.rs` + +**Step 1: Write the failing test** + +Add focused unit tests proving these are classified as whole-file tests: + +- `src/__tests__/app.ts` +- `web/app.test.tsx` +- `web/app.spec.jsx` +- `tests/e2e/login.ts` +- `cypress/e2e/home.cy.js` +- `playwright/auth.spec.ts` + +Also add negative cases such as: + +- `src/app.ts` +- `scripts/build.mjs` +- `playwright.config.ts` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test javascript_tests -v +``` + +Expected: FAIL because JS/TS whole-file test classification does not exist yet. + +**Step 3: Write minimal implementation** + +Implement path-based whole-file classification in `src/lang/javascript.rs` using path components and filename patterns only. Do not add AST parsing or inline test detection. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test --test javascript_tests -v +``` + +Expected: PASS + +### Task 4: Teach the shared test filter to handle whole-file-only languages + +**Files:** +- Modify: `src/test_filter.rs` +- Modify: `src/main.rs` +- Modify: `src/lang/javascript.rs` +- Modify: `tests/cli_smoke.rs` + +**Step 1: Write the failing test** + +Add CLI smoke coverage proving: + +- default `--no-test` excludes `*.test.tsx` and e2e files from the output +- `--test` includes those same files +- `--no-test-filter` reports them as ordinary full-file stats +- non-test JS/TS source files are still counted under `--no-test` + +Use a mixed repository that contains at least one frontend source file and at least one frontend test file. + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test cli_smoke -v +``` + +Expected: FAIL because the shared builder currently only dispatches Rust and Python test-aware behavior. + +**Step 3: Write minimal implementation** + +Extend `src/test_filter.rs` so JS/TS family backends can contribute whole-file test paths without region splitting. For files that are not whole-file tests, treat the full diff as non-test code. + +Keep the builder scoped to selected languages only. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test --test cli_smoke -v +``` + +Expected: PASS + +### Task 5: Avoid unnecessary frontend source reads during whole-file classification + +**Files:** +- Modify: `src/test_filter.rs` +- Modify: `src/lang/rust.rs` +- Modify: `src/lang/python.rs` +- Modify: `src/lang/javascript.rs` +- Modify: `tests/cli_smoke.rs` + +**Step 1: Write the failing test** + +Add a regression similar to the existing Python review fix: create a tracked JS/TS family file with non-UTF8 bytes that should be ignored when the selected languages do not include it, and prove the command still succeeds. + +Also add coverage proving JS/TS whole-file classification can be computed without bulk-reading frontend source contents when only path-based rules are needed. + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test cli_smoke -v +``` + +Expected: FAIL if the builder still eagerly reads irrelevant frontend sources. + +**Step 3: Write minimal implementation** + +Separate path-only whole-file classification from source-assisted whole-file classification inside `src/test_filter.rs`. Preserve Rust behavior for imported `#[cfg(test)]` modules while avoiding unnecessary JS/TS content reads. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test --test cli_smoke -v +``` + +Expected: PASS + +### Task 6: Update help text and README for all-supported-language defaults + +**Files:** +- Modify: `src/cli.rs` +- Modify: `README.md` +- Modify: `tests/cli_smoke.rs` +- Modify: `tests/readme_presence.rs` + +**Step 1: Write the failing test** + +Update help-text expectations and README assertions so they require: + +- default `--lang` meaning all supported languages +- examples that mention JS/TS family usage +- notes describing frontend whole-file test detection + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test cli_smoke help_mentions_common_examples -v +cargo test --test readme_presence -v +``` + +Expected: FAIL because docs and help still describe the older default set. + +**Step 3: Write minimal implementation** + +Update help examples, defaults text, README usage, and notes so the supported language list and default behavior are explicit and consistent with the registry. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test --test cli_smoke help_mentions_common_examples -v +cargo test --test readme_presence -v +``` + +Expected: PASS + +### Task 7: Run the full verification suite + +**Files:** +- Modify: none + +**Step 1: Run targeted tests** + +Run: + +```bash +cargo test --test lang_filter -v +cargo test --test javascript_tests -v +cargo test --test python_tests -v +cargo test --test rust_tests -v +cargo test --test cli_smoke -v +``` + +Expected: PASS + +**Step 2: Run the full test suite** + +Run: + +```bash +cargo test -v +``` + +Expected: PASS + +**Step 3: Run lint** + +Run: + +```bash +cargo clippy --all-targets --all-features -- -D warnings +``` + +Expected: PASS + +**Step 4: Commit** + +```bash +git add README.md src tests docs/plans/2026-03-21-js-ts-family-support-design.md docs/plans/2026-03-21-js-ts-family-support.md +git commit -m "feat: add js and ts family language support" +``` diff --git a/docs/plans/2026-03-21-python-lang-support-design.md b/docs/plans/2026-03-21-python-lang-support-design.md new file mode 100644 index 0000000..faa7529 --- /dev/null +++ b/docs/plans/2026-03-21-python-lang-support-design.md @@ -0,0 +1,178 @@ +# Python Language Support Design + +**Context** + +`git-diff-stat` currently treats `--lang` as a thin file-extension filter in [`src/lang.rs`](../../src/lang.rs), while Rust test-aware behavior is implemented separately in [`src/filter.rs`](../../src/filter.rs) and [`src/rust_tests.rs`](../../src/rust_tests.rs). This works for a single language, but it couples the main execution path to Rust-specific logic and makes each new language support request disproportionately expensive. + +The immediate goal is to support `--lang py` with the same test-filter semantics that Rust already participates in: + +- default / `--no-test`: report only non-test code +- `--test`: report only test code +- `--no-test-filter`: report full-file stats without test splitting + +The target Python test style is the one used by `winq_bt`: `pytest`-style test discovery centered around `tests/`, `test_*.py`, `*_test.py`, `conftest.py`, top-level `def test_*`, and `class Test*`. + +**Goal** + +Add first-class Python support without turning `main.rs` into a per-language switchboard. The structure should make future languages easier to add, while keeping the current Rust behavior unchanged. + +**Approaches** + +1. Extend the current code path with Python-specific branches in `main.rs` and `filter.rs`. + - Lowest short-term cost. + - Rejected because it keeps the architecture centered on Rust special-cases and makes future additions harder. + +2. Introduce lightweight language backends and move test-aware behavior behind a shared interface. + - Slightly more up-front work. + - Keeps CLI/rendering stable while moving language-specific rules into isolated modules. + - Recommended. + +3. Build a fully generic plugin system now. + - Over-designed for the current repository size and only one additional language. + - Rejected as unnecessary complexity. + +**Decision** + +Use lightweight language backends. + +The refactor should not aim for a public plugin API. It only needs enough structure to answer these questions in one place per language: + +- does this path belong to the language? +- which files are whole-file tests? +- for mixed files, which changed lines are test lines vs non-test lines? + +`main.rs` should keep orchestrating Git I/O, revision selection, rendering, and CLI interpretation. It should stop knowing Rust-specific details. + +**Proposed Structure** + +- `src/lang/mod.rs` + - language parsing and normalization + - registry of supported languages + - path-to-language detection +- `src/lang/rust.rs` + - wraps current Rust-specific behavior + - owns Rust whole-file test-path detection and line-region splitting +- `src/lang/python.rs` + - Python path matching and test-region detection +- `src/test_filter.rs` + - shared orchestration for building test-only or non-test-only stats across requested languages +- `src/rust_tests.rs` + - can remain as a Rust parser helper used by `src/lang/rust.rs` + +This is a moderate refactor, not a rewrite. Existing types such as `FileChange`, `FilePatch`, `DisplayStat`, and `TestFilterMode` remain useful as-is. + +**Backend Shape** + +The shared test-filter orchestration should operate in terms of a small internal backend contract. The exact Rust type names can vary, but the responsibilities should look like this: + +- language identity and aliases, such as `rs` and `py` +- file matching by extension +- optional whole-file-test classification +- optional per-file region splitting for tracked and untracked files + +One practical model is: + +- `LanguageKind` enum for supported languages +- helper functions in each language module instead of trait objects +- a dispatcher in `test_filter.rs` that groups changed files by language and invokes the relevant backend helpers + +This avoids unnecessary dynamic dispatch while still removing language conditionals from `main.rs`. + +**Python Test Semantics** + +Python support should match common `pytest` conventions first. + +Whole-file test rules: + +- any `.py` file under a `tests/` path component +- `test_*.py` +- `*_test.py` +- `conftest.py` + +Mixed-file region rules: + +- top-level `def test_*` +- methods named `test_*` +- `class Test*` + +That gives useful behavior for projects like `winq_bt` without trying to model every Python test framework on day one. + +Not in scope for the first version: + +- full `unittest.TestCase` inference beyond names already covered by `test_*` +- custom pytest discovery configuration +- doctests +- dynamic test generation + +**Parsing Strategy** + +Use `tree-sitter-python` alongside the existing `tree-sitter` setup. + +This aligns with the current Rust implementation style: + +- accurate line ranges for test functions and classes +- support for tracked diffs and untracked files +- no need to invent a fragile indentation-based parser + +The Python parser only needs to detect class and function definition ranges. It does not need semantic import resolution similar to Rust's `#[cfg(test)] mod` handling. + +**Data Flow** + +The runtime flow should become: + +1. Parse CLI and revision selection. +2. Parse requested languages into supported language kinds. +3. Filter `FileChange` values by requested languages. +4. If `--no-test-filter`, render full-file stats directly. +5. Otherwise, call a shared test-aware stats builder. +6. The builder: + - parses the diff patch once + - loads per-revision or worktree sources as needed + - asks each language backend for whole-file test paths + - asks each language backend to split changed lines into test/non-test counts +7. Render using the existing header machinery. + +The important shift is that the builder should work over "requested supported languages", not over "Rust files only". + +**Compatibility Rules** + +- Default `--lang` remains `rs` for now. +- `--lang py` participates in the same `--test`, `--no-test`, and `--no-test-filter` flags. +- `--lang rs,py` should combine both backends in one run. +- Non-test-filtered output remains full-file diff stats for any selected language. +- Unknown or unsupported language names should continue to be ignored or rejected consistently with current behavior; if validation is added, it should happen centrally in `src/lang/mod.rs`. + +**Testing Strategy** + +Add coverage at three layers: + +1. Unit tests for language recognition and normalization. +2. Unit tests for Python test-region detection and whole-file test-path classification. +3. CLI smoke tests proving end-to-end behavior for: + - `--lang py` default non-test filtering + - `--lang py --test` + - `--lang py --no-test-filter` + - mixed `--lang rs,py` + +Python smoke tests should include: + +- a production file under `src/` +- a `tests/test_*.py` file +- a mixed file containing both production code and `def test_*` +- optionally `conftest.py` to prove whole-file test classification + +**Risks** + +- The current loader helpers in `main.rs` are named and shaped around Rust sources. Moving them into shared test-filter orchestration will require careful renaming so behavior does not regress. +- Path normalization rules must stay consistent across languages, especially for whole-file test matching. +- Python region detection should stay intentionally narrow; a too-clever first version is more likely to misclassify production code. + +**Outcome** + +After this refactor, adding a new language should mainly mean: + +1. register a new language kind +2. implement one language module +3. add backend tests and one or two CLI regressions + +That is the right level of structure for the repository at its current size. diff --git a/docs/plans/2026-03-21-python-lang-support.md b/docs/plans/2026-03-21-python-lang-support.md new file mode 100644 index 0000000..a958529 --- /dev/null +++ b/docs/plans/2026-03-21-python-lang-support.md @@ -0,0 +1,230 @@ +# Python Language Support Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add `--lang py` with Python test-aware filtering and refactor language-specific filtering so Rust and Python both run through the same orchestration path. + +**Architecture:** Replace the current Rust-only test-filter path with lightweight language backends. Keep CLI and rendering behavior stable, move Rust-specific logic behind `src/lang/rust.rs`, add a Python backend in `src/lang/python.rs`, and centralize multi-language test-aware stat construction in a shared module. + +**Tech Stack:** Rust, clap, tree-sitter, tree-sitter-rust, tree-sitter-python, assert_cmd, predicates, cargo test + +--- + +### Task 1: Lock down `py` language selection behavior + +**Files:** +- Modify: `tests/lang_filter.rs` +- Modify: `tests/cli_smoke.rs` +- Modify: `README.md` + +**Step 1: Write the failing test** + +Add one unit test proving `filter_by_langs` keeps `.py` files for `["py"]`, and one CLI smoke test proving `--lang py --no-test-filter` includes a Python file and excludes the default Rust-only path when Python is explicitly requested. + +**Step 2: Run test to verify it fails** + +Run: `cargo test --test lang_filter --test cli_smoke -v` +Expected: FAIL because `.py` is not a recognized language yet. + +**Step 3: Write minimal implementation** + +Teach language detection to recognize `.py` and update README usage examples so Python is documented as a supported language. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --test lang_filter --test cli_smoke -v` +Expected: PASS + +### Task 2: Introduce a language module layout that can host multiple backends + +**Files:** +- Create: `src/lang/mod.rs` +- Create: `src/lang/rust.rs` +- Create: `src/lang/python.rs` +- Modify: `src/lib.rs` +- Modify: `src/main.rs` +- Modify: `src/lang.rs` or move its contents into `src/lang/mod.rs` + +**Step 1: Write the failing test** + +Add or adjust unit tests so language parsing still accepts `rs`, `js`, `ts`, and now `py`, and so path-based language detection works after the module split. + +**Step 2: Run test to verify it fails** + +Run: `cargo test --test lang_filter -v` +Expected: FAIL while the old single-file `lang.rs` layout is being replaced. + +**Step 3: Write minimal implementation** + +Move language parsing and extension detection into `src/lang/mod.rs`. Add backend-oriented helpers in `src/lang/rust.rs` and `src/lang/python.rs`. Keep the public surface small: parsing requested languages, detecting a file's language, and exposing backend helpers needed by test filtering. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --test lang_filter -v` +Expected: PASS + +### Task 3: Add Python test detection primitives + +**Files:** +- Create: `src/python_tests.rs` or keep parser helpers inside `src/lang/python.rs` +- Create: `tests/python_tests.rs` +- Modify: `Cargo.toml` + +**Step 1: Write the failing test** + +Add focused tests proving Python detection marks these as test code: + +```python +def test_basic(): + assert True + +class TestApi: + def test_fetch(self): + assert True +``` + +Also add tests proving a production helper such as `def build_report():` is not test code, and path tests proving `tests/foo.py`, `test_bar.py`, `bar_test.py`, and `conftest.py` are whole-file tests. + +**Step 2: Run test to verify it fails** + +Run: `cargo test --test python_tests -v` +Expected: FAIL because Python parsing and path classification do not exist yet. + +**Step 3: Write minimal implementation** + +Add `tree-sitter-python` and implement Python helpers for: + +- whole-file test path detection +- line-region detection for test functions and `class Test*` +- untracked file splitting +- tracked patch splitting + +Keep the first version intentionally narrow and pytest-oriented. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --test python_tests -v` +Expected: PASS + +### Task 4: Replace the Rust-only builder with shared multi-language test filtering + +**Files:** +- Create: `src/test_filter.rs` +- Modify: `src/main.rs` +- Modify: `src/filter.rs` +- Modify: `src/lang/rust.rs` +- Modify: `src/lang/python.rs` + +**Step 1: Write the failing test** + +Add or update smoke coverage to prove: + +- `--lang py` default output excludes Python test-only changes +- `--lang py --test` includes only Python test changes +- `--lang rs,py` handles both languages in one run + +**Step 2: Run test to verify it fails** + +Run: `cargo test --test cli_smoke -v` +Expected: FAIL because `main.rs` still only builds test-aware stats for Rust files. + +**Step 3: Write minimal implementation** + +Extract the current Rust-only test-aware stat builder from `main.rs` into a shared module. Generalize the source-loading helpers so they operate on selected language kinds, not only Rust. Dispatch whole-file test detection and region splitting through the relevant language backend, while preserving current Rust behavior. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --test cli_smoke -v` +Expected: PASS + +### Task 5: Preserve Rust behavior during the refactor + +**Files:** +- Modify: `tests/rust_tests.rs` +- Modify: `tests/cli_smoke.rs` +- Modify: `tests/lang_filter.rs` +- Modify: `src/rust_tests.rs` +- Modify: `src/lang/rust.rs` + +**Step 1: Write the failing test** + +Add one regression proving a Rust integration test file is still treated as a whole-file test after the backend extraction, and one regression proving mixed Rust source with `#[cfg(test)]` still splits lines correctly. + +**Step 2: Run test to verify it fails** + +Run: `cargo test --test rust_tests --test cli_smoke -v` +Expected: FAIL if the refactor changes Rust classification behavior. + +**Step 3: Write minimal implementation** + +Move Rust-specific orchestration into `src/lang/rust.rs` without changing the existing region and imported-module semantics. Keep `src/rust_tests.rs` as a parsing helper if that reduces churn. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --test rust_tests --test cli_smoke -v` +Expected: PASS + +### Task 6: Update help text and docs for Python support + +**Files:** +- Modify: `src/cli.rs` +- Modify: `README.md` +- Modify: `tests/cli_smoke.rs` + +**Step 1: Write the failing test** + +Adjust help-text expectations so examples mention `--lang py` as a supported, test-aware language path and keep the existing Rust-default examples intact. + +**Step 2: Run test to verify it fails** + +Run: `cargo test --test cli_smoke help_mentions_common_examples -v` +Expected: FAIL because help text still only reflects the old language set. + +**Step 3: Write minimal implementation** + +Update `after_help`, usage examples, and README notes to explain: + +- `--lang py` +- pytest-oriented Python test detection +- default `--lang rs` +- `--no-test-filter` behavior across selected languages + +**Step 4: Run test to verify it passes** + +Run: `cargo test --test cli_smoke help_mentions_common_examples -v` +Expected: PASS + +### Task 7: Run the full verification suite + +**Files:** +- Modify: none + +**Step 1: Run targeted tests** + +Run: + +```bash +cargo test --test lang_filter -v +cargo test --test python_tests -v +cargo test --test rust_tests -v +cargo test --test cli_smoke -v +``` + +Expected: PASS + +**Step 2: Run the full test suite** + +Run: `cargo test -v` +Expected: PASS + +**Step 3: Run lint if available** + +Run: `cargo clippy --all-targets --all-features -- -D warnings` +Expected: PASS + +**Step 4: Commit** + +```bash +git add Cargo.toml Cargo.lock src tests README.md docs/plans/2026-03-21-python-lang-support-design.md docs/plans/2026-03-21-python-lang-support.md +git commit -m "feat: add python test-aware language support" +``` diff --git a/src/cli.rs b/src/cli.rs index c2d82ff..0da3370 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,16 +11,16 @@ pub enum TestFilterMode { #[command(name = "git-diff-stat")] #[command(about = "Enhanced git diff --stat with untracked and test filtering")] #[command( - after_help = "Examples:\n git diff-stat --commit HEAD\n git diff-stat --last\n git diff-stat --last --no-test-filter\n git diff-stat HEAD~1..HEAD --lang py --no-test-filter\n git diff-stat --test" + after_help = "Examples:\n git diff-stat\n git diff-stat --commit HEAD\n git diff-stat --last\n git diff-stat --last --no-test-filter\n git diff-stat HEAD~1..HEAD --lang py --no-test-filter\n git diff-stat --lang py --test\n git diff-stat --lang tsx --test\n git diff-stat --test\n\nDefaults:\n --lang all supported languages (rs,py,js,ts,jsx,tsx,cjs,mjs)\n test filter: --non-test" )] pub struct Cli { - #[arg(long, conflicts_with_all = ["no_test", "no_test_filter"])] + #[arg(long, conflicts_with_all = ["non_test", "no_test_filter"])] pub test: bool, #[arg(long, conflicts_with_all = ["test", "no_test_filter"])] - pub no_test: bool, + pub non_test: bool, - #[arg(long, conflicts_with_all = ["test", "no_test"])] + #[arg(long, conflicts_with_all = ["test", "non_test"])] pub no_test_filter: bool, #[arg(long, value_name = "REV", conflicts_with_all = ["last", "revisions"])] @@ -29,7 +29,7 @@ pub struct Cli { #[arg(long, conflicts_with_all = ["commit", "revisions"])] pub last: bool, - #[arg(long, value_name = "LANGS", default_value = "rs")] + #[arg(long, value_name = "LANGS")] pub lang: Option, #[arg(value_name = "REVISION")] diff --git a/src/lang.rs b/src/lang.rs deleted file mode 100644 index 0e3d6a0..0000000 --- a/src/lang.rs +++ /dev/null @@ -1,33 +0,0 @@ -use std::path::Path; - -use crate::change::FileChange; - -pub fn filter_by_langs(changes: &[FileChange], langs: &[&str]) -> Result, String> { - let requested = langs - .iter() - .map(|lang| normalize_lang(lang)) - .collect::>(); - - Ok(changes - .iter() - .filter(|change| { - detect_language(&change.path) - .map(|language| requested.contains(&language)) - .unwrap_or(false) - }) - .cloned() - .collect()) -} - -fn normalize_lang(lang: &str) -> &str { - lang.trim() -} - -fn detect_language(path: &str) -> Option<&'static str> { - match Path::new(path).extension().and_then(|ext| ext.to_str()) { - Some("rs") => Some("rs"), - Some("js") => Some("js"), - Some("ts") => Some("ts"), - _ => None, - } -} diff --git a/src/lang/javascript.rs b/src/lang/javascript.rs new file mode 100644 index 0000000..93fe975 --- /dev/null +++ b/src/lang/javascript.rs @@ -0,0 +1,54 @@ +use std::collections::HashSet; +use std::path::Path; + +const JS_TS_FAMILY_LANGS: &[&str] = &["js", "ts", "jsx", "tsx", "cjs", "mjs"]; + +pub fn family_langs() -> &'static [&'static str] { + JS_TS_FAMILY_LANGS +} + +pub fn detect_language(path: &str) -> Option<&'static str> { + match Path::new(path).extension().and_then(|ext| ext.to_str()) { + Some("js") => Some("js"), + Some("ts") => Some("ts"), + Some("jsx") => Some("jsx"), + Some("tsx") => Some("tsx"), + Some("cjs") => Some("cjs"), + Some("mjs") => Some("mjs"), + _ => None, + } +} + +pub fn collect_whole_test_paths(sources: &[(String, String)]) -> Result, String> { + Ok(sources + .iter() + .map(|(path, _)| path) + .filter(|path| is_whole_test_path(path)) + .cloned() + .collect()) +} + +fn is_whole_test_path(path: &str) -> bool { + let Some(language) = detect_language(path) else { + return false; + }; + + if Path::new(path).components().any(|component| { + matches!( + component.as_os_str().to_str(), + Some("__tests__" | "e2e" | "cypress" | "playwright") + ) + }) { + return true; + } + + let filename = Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + let Some(stem) = filename.strip_suffix(&format!(".{language}")) else { + return false; + }; + + stem.ends_with(".test") || stem.ends_with(".spec") || stem.ends_with(".cy") +} diff --git a/src/lang/mod.rs b/src/lang/mod.rs new file mode 100644 index 0000000..5788e92 --- /dev/null +++ b/src/lang/mod.rs @@ -0,0 +1,55 @@ +use crate::change::FileChange; + +const SUPPORTED_LANGS: &[&str] = &["rs", "py", "js", "ts", "jsx", "tsx", "cjs", "mjs"]; + +pub mod javascript; +pub mod python; +pub mod rust; + +pub fn filter_by_langs(changes: &[FileChange], langs: &[&str]) -> Result, String> { + let requested = langs + .iter() + .map(|lang| normalize_lang(lang)) + .collect::>(); + + Ok(changes + .iter() + .filter(|change| { + detect_language(&change.old_path) + .into_iter() + .chain(detect_language(&change.new_path)) + .any(|language| requested.contains(&language)) + }) + .cloned() + .collect()) +} + +pub fn parse_langs(value: Option<&str>) -> Vec<&str> { + value + .map(|value| { + value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect() + }) + .unwrap_or_else(|| supported_langs().to_vec()) +} + +pub fn supported_langs() -> &'static [&'static str] { + SUPPORTED_LANGS +} + +pub fn detect_language(path: &str) -> Option<&'static str> { + if rust::matches_path(path) { + Some("rs") + } else if python::matches_path(path) { + Some("py") + } else { + javascript::detect_language(path) + } +} + +fn normalize_lang(lang: &str) -> &str { + lang.trim() +} diff --git a/src/lang/python.rs b/src/lang/python.rs new file mode 100644 index 0000000..329560f --- /dev/null +++ b/src/lang/python.rs @@ -0,0 +1,29 @@ +use std::path::Path; + +use crate::patch::FilePatch; +use crate::python_tests::{ + PythonTestSplit, collect_python_whole_test_paths, split_file_patch_for_python_tests, + split_untracked_python_source, +}; + +pub fn matches_path(path: &str) -> bool { + Path::new(path).extension().and_then(|ext| ext.to_str()) == Some("py") +} + +pub fn collect_whole_test_paths( + sources: &[(String, String)], +) -> Result, String> { + collect_python_whole_test_paths(sources) +} + +pub fn split_file_patch( + patch: &FilePatch, + old_source: &str, + new_source: &str, +) -> Result { + split_file_patch_for_python_tests(patch, old_source, new_source) +} + +pub fn split_untracked_source(source: &str) -> Result { + split_untracked_python_source(source) +} diff --git a/src/lang/rust.rs b/src/lang/rust.rs new file mode 100644 index 0000000..fbab81d --- /dev/null +++ b/src/lang/rust.rs @@ -0,0 +1,29 @@ +use std::path::Path; + +use crate::filter::{ + RustTestSplit, collect_rust_whole_test_paths, split_file_patch_for_rust_tests, + split_untracked_rust_source, +}; +use crate::patch::FilePatch; + +pub fn matches_path(path: &str) -> bool { + Path::new(path).extension().and_then(|ext| ext.to_str()) == Some("rs") +} + +pub fn collect_whole_test_paths( + sources: &[(String, String)], +) -> Result, String> { + collect_rust_whole_test_paths(sources) +} + +pub fn split_file_patch( + patch: &FilePatch, + old_source: &str, + new_source: &str, +) -> Result { + split_file_patch_for_rust_tests(patch, old_source, new_source) +} + +pub fn split_untracked_source(source: &str) -> Result { + split_untracked_rust_source(source) +} diff --git a/src/lib.rs b/src/lib.rs index 6258d70..41e9499 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,8 @@ pub mod filter; pub mod git; pub mod lang; pub mod patch; +pub mod python_tests; pub mod render; pub mod revision; pub mod rust_tests; +pub mod test_filter; diff --git a/src/main.rs b/src/main.rs index 2949696..fd1b354 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,12 @@ -use std::collections::HashMap; use std::env; -use git_diff_stat::change::{FileChange, collect_changes}; +use git_diff_stat::change::collect_changes; use git_diff_stat::cli::{Cli, TestFilterMode}; -use git_diff_stat::filter::{ - collect_rust_whole_test_paths, split_file_patch_for_rust_tests, split_untracked_rust_source, -}; use git_diff_stat::git::Git; -use git_diff_stat::lang::filter_by_langs; -use git_diff_stat::patch::parse_patch; -use git_diff_stat::render::{DisplayStat, StatsDescription, render_stats}; +use git_diff_stat::lang::{filter_by_langs, parse_langs}; +use git_diff_stat::render::{StatsDescription, render_stats}; use git_diff_stat::revision::RevisionSelection; +use git_diff_stat::test_filter::build_test_filtered_stats; fn main() { if let Err(error) = run() { @@ -24,24 +20,14 @@ fn run() -> Result<(), String> { let git = Git::new(env::current_dir().map_err(|error| format!("failed to read cwd: {error}"))?); let selection = RevisionSelection::from_cli(&cli)?; let mut changes = collect_changes(&git, &selection)?; - let langs = parse_langs(cli.lang.as_deref()).unwrap_or_default(); + let langs = parse_langs(cli.lang.as_deref()); if !langs.is_empty() { changes = filter_by_langs(&changes, &langs)?; } - let stats = match cli.test_filter_mode() { - TestFilterMode::TestOnly => build_rust_test_stats(&git, &selection, &changes, true)?, - TestFilterMode::NonTestOnly => build_rust_test_stats(&git, &selection, &changes, false)?, - TestFilterMode::All => changes - .into_iter() - .map(|change| DisplayStat { - path: change.path, - added: change.added, - deleted: change.deleted, - }) - .collect(), - }; + let stats = + build_test_filtered_stats(&git, &selection, &changes, &langs, cli.test_filter_mode())?; let description = StatsDescription { comparison_scope: selection.describe_scope(&git, cli.last)?, @@ -53,176 +39,18 @@ fn run() -> Result<(), String> { Ok(()) } -fn build_rust_test_stats( - git: &Git, - selection: &RevisionSelection, - changes: &[FileChange], - test_only: bool, -) -> Result, String> { - let patch_output = git.diff_patch(&selection.git_diff_args())?; - let patch = parse_patch(&patch_output)?; - let patch_map = patch - .files - .into_iter() - .map(|file| (file.path.clone(), file)) - .collect::>(); - let endpoints = selection.endpoints(git)?; - let whole_test_paths = build_whole_test_paths(git, endpoints.as_ref())?; - let mut stats = Vec::new(); - - for change in changes { - if !change.old_path.ends_with(".rs") && !change.new_path.ends_with(".rs") { - continue; - } - - if change.added + change.deleted == 0 { - continue; - } - - let old_is_whole_test = whole_test_paths.old.contains(&change.old_path); - let new_is_whole_test = whole_test_paths.new.contains(&change.new_path); - let (added, deleted) = if old_is_whole_test || new_is_whole_test { - if test_only { - ( - if new_is_whole_test { change.added } else { 0 }, - if old_is_whole_test { change.deleted } else { 0 }, - ) - } else { - ( - if new_is_whole_test { 0 } else { change.added }, - if old_is_whole_test { 0 } else { change.deleted }, - ) - } - } else if change.untracked { - let source = git.read_worktree_file(&change.new_path)?; - let split = split_untracked_rust_source(&source)?; - if test_only { - (split.test_added, split.test_deleted) - } else { - (split.non_test_added, split.non_test_deleted) - } - } else { - let file_patch = patch_map - .get(&change.new_path) - .ok_or_else(|| format!("missing patch data for {}", change.path))?; - let old_source = match &endpoints { - Some(endpoints) => git - .show_file_at_revision(&endpoints.old, &change.old_path) - .unwrap_or_default(), - None => git.show_index_file(&change.old_path).unwrap_or_default(), - }; - let new_source = match &endpoints { - Some(endpoints) => git - .show_file_at_revision(&endpoints.new, &change.new_path) - .unwrap_or_default(), - None => git.read_worktree_file(&change.new_path).unwrap_or_default(), - }; - let split = split_file_patch_for_rust_tests(file_patch, &old_source, &new_source)?; - if test_only { - (split.test_added, split.test_deleted) - } else { - (split.non_test_added, split.non_test_deleted) - } - }; - - if added + deleted == 0 { - continue; - } - - stats.push(DisplayStat { - path: change.path.clone(), - added, - deleted, - }); - } - - Ok(stats) -} - -struct WholeTestPaths { - old: std::collections::HashSet, - new: std::collections::HashSet, -} - -fn build_whole_test_paths( - git: &Git, - endpoints: Option<&git_diff_stat::revision::RevisionEndpoints>, -) -> Result { - let (old_sources, new_sources) = match endpoints { - Some(endpoints) => ( - load_revision_rust_sources(git, &endpoints.old)?, - load_revision_rust_sources(git, &endpoints.new)?, - ), - None => ( - load_index_rust_sources(git)?, - load_worktree_rust_sources(git)?, - ), - }; - Ok(WholeTestPaths { - old: collect_rust_whole_test_paths(&old_sources)?, - new: collect_rust_whole_test_paths(&new_sources)?, - }) -} - -fn load_index_rust_sources(git: &Git) -> Result, String> { - load_rust_sources(git.tracked_files()?, |path| git.show_index_file(path)) -} - -fn load_worktree_rust_sources(git: &Git) -> Result, String> { - let mut paths = git.tracked_files()?; - paths.retain(|path| git.worktree_file_exists(path)); - paths.extend(git.untracked_files()?); - load_rust_sources(paths, |path| git.read_worktree_file(path)) -} - -fn load_revision_rust_sources(git: &Git, revision: &str) -> Result, String> { - load_rust_sources(git.revision_files(revision)?, |path| { - git.show_file_at_revision(revision, path) - }) -} - -fn load_rust_sources( - paths: Vec, - mut read_source: F, -) -> Result, String> -where - F: FnMut(&str) -> Result, -{ - let mut sources = Vec::new(); - - for path in paths { - if !path.ends_with(".rs") { - continue; - } - - sources.push((path.clone(), read_source(&path)?)); - } - - Ok(sources) -} - -fn parse_langs(value: Option<&str>) -> Option> { - value.map(|value| { - value - .split(',') - .map(str::trim) - .filter(|value| !value.is_empty()) - .collect() - }) -} - fn describe_language_scope(langs: &[&str]) -> String { if langs.is_empty() { - "所有文件".to_string() + "all files".to_string() } else { - format!("{} 文件", langs.join(",")) + format!("{} files", langs.join(",")) } } fn describe_test_scope(mode: TestFilterMode) -> String { match mode { - TestFilterMode::TestOnly => "测试代码".to_string(), - TestFilterMode::NonTestOnly => "非测试代码".to_string(), - TestFilterMode::All => "测试与非测试代码".to_string(), + TestFilterMode::TestOnly => "Test code".to_string(), + TestFilterMode::NonTestOnly => "Non-test code".to_string(), + TestFilterMode::All => "Test and non-test code".to_string(), } } diff --git a/src/python_tests.rs b/src/python_tests.rs new file mode 100644 index 0000000..6decb16 --- /dev/null +++ b/src/python_tests.rs @@ -0,0 +1,238 @@ +use std::collections::HashSet; +use std::path::Path; + +use tree_sitter::{Node, Parser}; + +use crate::change::line_count; +use crate::patch::{FilePatch, LineKind}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TestRegions { + regions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PythonTestSplit { + pub test_added: usize, + pub test_deleted: usize, + pub non_test_added: usize, + pub non_test_deleted: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LineRange { + start: usize, + end: usize, +} + +impl TestRegions { + pub fn contains_line(&self, line: usize) -> bool { + self.regions + .iter() + .any(|region| region.start <= line && line <= region.end) + } +} + +pub fn collect_python_whole_test_paths( + sources: &[(String, String)], +) -> Result, String> { + Ok(sources + .iter() + .map(|(path, _)| path) + .filter(|path| is_python_whole_test_path(path)) + .cloned() + .collect()) +} + +pub fn detect_test_regions(source: &str) -> Result { + let tree = parse_python_source(source)?; + let mut regions = Vec::new(); + collect_regions(tree.root_node(), source.as_bytes(), &mut regions)?; + + Ok(TestRegions { regions }) +} + +pub fn split_file_patch_for_python_tests( + patch: &FilePatch, + old_source: &str, + new_source: &str, +) -> Result { + let old_regions = detect_test_regions(old_source)?; + let new_regions = detect_test_regions(new_source)?; + let mut split = PythonTestSplit { + test_added: 0, + test_deleted: 0, + non_test_added: 0, + non_test_deleted: 0, + }; + + for event in &patch.line_events { + match event.kind { + LineKind::Added => { + let line = event + .new_line + .ok_or_else(|| "added line event missing new line".to_string())?; + if new_regions.contains_line(line) { + split.test_added += 1; + } else { + split.non_test_added += 1; + } + } + LineKind::Deleted => { + let line = event + .old_line + .ok_or_else(|| "deleted line event missing old line".to_string())?; + if old_regions.contains_line(line) { + split.test_deleted += 1; + } else { + split.non_test_deleted += 1; + } + } + } + } + + Ok(split) +} + +pub fn split_untracked_python_source(source: &str) -> Result { + let regions = detect_test_regions(source)?; + let mut split = PythonTestSplit { + test_added: 0, + test_deleted: 0, + non_test_added: 0, + non_test_deleted: 0, + }; + + for line in 1..=line_count(source) { + if regions.contains_line(line) { + split.test_added += 1; + } else { + split.non_test_added += 1; + } + } + + Ok(split) +} + +pub fn is_python_whole_test_path(path: &str) -> bool { + if Path::new(path).extension().and_then(|ext| ext.to_str()) != Some("py") { + return false; + } + + let filename = Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + if filename == "conftest.py" || filename.starts_with("test_") || filename.ends_with("_test.py") + { + return true; + } + + Path::new(path) + .components() + .any(|component| component.as_os_str() == "tests") +} + +fn parse_python_source(source: &str) -> Result { + let mut parser = Parser::new(); + let language = tree_sitter_python::LANGUAGE.into(); + parser + .set_language(&language) + .map_err(|error| format!("failed to load python grammar: {error}"))?; + + parser + .parse(source, None) + .ok_or_else(|| "failed to parse python source".to_string()) +} + +fn collect_regions( + node: Node<'_>, + source: &[u8], + regions: &mut Vec, +) -> Result<(), String> { + if let Some(range) = test_range_for_node(node, source)? { + regions.push(range); + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_regions(child, source, regions)?; + } + + Ok(()) +} + +fn test_range_for_node(node: Node<'_>, source: &[u8]) -> Result, String> { + if node.kind() == "decorated_definition" { + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + if is_test_definition(child, source)? { + let range = node.range(); + return Ok(Some(LineRange { + start: range.start_point.row + 1, + end: range.end_point.row + 1, + })); + } + } + return Ok(None); + } + + if is_test_definition(node, source)? { + let range = node.range(); + return Ok(Some(LineRange { + start: range.start_point.row + 1, + end: range.end_point.row + 1, + })); + } + + Ok(None) +} + +fn is_test_definition(node: Node<'_>, source: &[u8]) -> Result { + match node.kind() { + "function_definition" => Ok(is_top_level_definition(node) + && extract_name(node, source)? + .map(|name| name.starts_with("test_")) + .unwrap_or(false)), + "class_definition" => Ok(is_top_level_definition(node) + && extract_name(node, source)? + .map(|name| name.starts_with("Test")) + .unwrap_or(false)), + _ => Ok(false), + } +} + +fn is_top_level_definition(node: Node<'_>) -> bool { + let mut parent = node.parent(); + + while let Some(current) = parent { + match current.kind() { + "block" | "decorated_definition" => parent = current.parent(), + "module" => return true, + _ => return false, + } + } + + false +} + +fn extract_name(node: Node<'_>, source: &[u8]) -> Result, String> { + if let Some(name) = node.child_by_field_name("name") { + return name + .utf8_text(source) + .map(|text| Some(text.to_string())) + .map_err(|error| format!("invalid utf8 in python identifier: {error}")); + } + + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + if child.kind() == "identifier" { + return child + .utf8_text(source) + .map(|text| Some(text.to_string())) + .map_err(|error| format!("invalid utf8 in python identifier: {error}")); + } + } + + Ok(None) +} diff --git a/src/render.rs b/src/render.rs index f784faa..bea9dc6 100644 --- a/src/render.rs +++ b/src/render.rs @@ -18,8 +18,8 @@ pub fn render_stats(description: &StatsDescription, stats: &[DisplayStat]) -> St let mut total_deleted = 0usize; lines.push(format!( - "{} {}中,{}统计如下:", - description.comparison_scope, description.language_scope, description.test_scope + "{} stats for {} {}:", + description.test_scope, description.language_scope, description.comparison_scope )); for stat in stats { diff --git a/src/revision.rs b/src/revision.rs index 8e567ba..9df2fe3 100644 --- a/src/revision.rs +++ b/src/revision.rs @@ -63,19 +63,19 @@ impl RevisionSelection { pub fn describe_scope(&self, git: &Git, last_flag: bool) -> Result { match self { - Self::WorkingTree => Ok("未提交的".to_string()), + Self::WorkingTree => Ok("in the working tree".to_string()), Self::CommitPatch(revision) => { if last_flag { - Ok("最后一次提交的".to_string()) + Ok("in the last commit".to_string()) } else { - Ok(format!("{revision} 这个提交的")) + Ok(format!("in commit {revision}")) } } Self::Revisions(_) => { let endpoints = self .endpoints(git)? .ok_or_else(|| "missing revision endpoints".to_string())?; - Ok(format!("{} 到 {} 的", endpoints.old, endpoints.new)) + Ok(format!("from {} to {}", endpoints.old, endpoints.new)) } } } diff --git a/src/test_filter.rs b/src/test_filter.rs new file mode 100644 index 0000000..9c78e6f --- /dev/null +++ b/src/test_filter.rs @@ -0,0 +1,673 @@ +use std::collections::{HashMap, HashSet}; + +use crate::change::FileChange; +use crate::cli::TestFilterMode; +use crate::git::Git; +use crate::lang::{detect_language, javascript, python, rust}; +use crate::patch::parse_patch; +use crate::render::DisplayStat; +use crate::revision::{RevisionEndpoints, RevisionSelection}; + +pub fn build_test_filtered_stats( + git: &Git, + selection: &RevisionSelection, + changes: &[FileChange], + langs: &[&str], + mode: TestFilterMode, +) -> Result, String> { + if mode == TestFilterMode::All { + return Ok(build_unfiltered_stats(changes, langs)); + } + + let patch_output = git.diff_patch(&selection.git_diff_args())?; + let patch = parse_patch(&patch_output)?; + let patch_map = patch + .files + .into_iter() + .map(|file| (file.path.clone(), file)) + .collect::>(); + let endpoints = selection.endpoints(git)?; + let whole_test_paths = build_whole_test_paths(git, endpoints.as_ref(), langs)?; + let context = BuildContext { + git, + endpoints: &endpoints, + patch_map: &patch_map, + langs, + mode, + }; + let mut stats = Vec::new(); + + for change in changes { + let old_language = detect_language(&change.old_path); + let new_language = detect_language(&change.new_path); + let Some(language) = new_language.or(old_language) else { + continue; + }; + + if change.added + change.deleted == 0 { + if change.old_path != change.new_path { + stats.push(DisplayStat { + path: change.path.clone(), + added: 0, + deleted: 0, + }); + } + continue; + } + + let (added, deleted) = match (old_language, new_language) { + (old, new) if old != new => build_counts_for_mixed_language_change( + &context, + &whole_test_paths, + change, + old, + new, + )?, + _ => match language { + "rs" => build_counts_for_rust(&context, &whole_test_paths, change)?, + "py" => build_counts_for_python(&context, &whole_test_paths, change)?, + "js" | "ts" | "jsx" | "tsx" | "cjs" | "mjs" => { + build_counts_for_javascript(&context, &whole_test_paths, change) + } + _ => continue, + }, + }; + + if added + deleted == 0 { + continue; + } + + stats.push(DisplayStat { + path: change.path.clone(), + added, + deleted, + }); + } + + Ok(stats) +} + +fn build_unfiltered_stats(changes: &[FileChange], langs: &[&str]) -> Vec { + let mut stats = Vec::new(); + + for change in changes { + let old_language = detect_language(&change.old_path); + let new_language = detect_language(&change.new_path); + let added = selected_side_change_count(new_language, change.added, langs); + let deleted = selected_side_change_count(old_language, change.deleted, langs); + + if added + deleted == 0 && change.added + change.deleted > 0 { + continue; + } + + stats.push(DisplayStat { + path: change.path.clone(), + added, + deleted, + }); + } + + stats +} + +fn selected_side_change_count( + language: Option<&'static str>, + count: usize, + langs: &[&str], +) -> usize { + match language { + Some(language) if langs.is_empty() || langs.contains(&language) => count, + _ => 0, + } +} + +struct WholeTestPaths { + old: HashMap<&'static str, HashSet>, + new: HashMap<&'static str, HashSet>, +} + +struct BuildContext<'a> { + git: &'a Git, + endpoints: &'a Option, + patch_map: &'a HashMap, + langs: &'a [&'a str], + mode: TestFilterMode, +} + +fn build_whole_test_paths( + git: &Git, + endpoints: Option<&RevisionEndpoints>, + langs: &[&str], +) -> Result { + let (old_paths, new_paths) = match endpoints { + Some(endpoints) => ( + load_revision_paths(git, &endpoints.old, langs)?, + load_revision_paths(git, &endpoints.new, langs)?, + ), + None => ( + load_index_paths(git, langs)?, + load_worktree_paths(git, langs)?, + ), + }; + let (old_rust_sources, new_rust_sources) = if langs.contains(&"rs") { + match endpoints { + Some(endpoints) => ( + load_revision_sources(git, &endpoints.old, &["rs"])?, + load_revision_sources(git, &endpoints.new, &["rs"])?, + ), + None => ( + load_index_sources(git, &["rs"])?, + load_worktree_sources(git, &["rs"])?, + ), + } + } else { + (Vec::new(), Vec::new()) + }; + let old_path_entries = path_entries(&old_paths); + let new_path_entries = path_entries(&new_paths); + + let mut old = HashMap::new(); + old.insert("rs", rust::collect_whole_test_paths(&old_rust_sources)?); + old.insert("py", python::collect_whole_test_paths(&old_path_entries)?); + let old_javascript_paths = javascript::collect_whole_test_paths(&old_path_entries)?; + for language in javascript::family_langs() { + old.insert(*language, old_javascript_paths.clone()); + } + + let mut new = HashMap::new(); + new.insert("rs", rust::collect_whole_test_paths(&new_rust_sources)?); + new.insert("py", python::collect_whole_test_paths(&new_path_entries)?); + let new_javascript_paths = javascript::collect_whole_test_paths(&new_path_entries)?; + for language in javascript::family_langs() { + new.insert(*language, new_javascript_paths.clone()); + } + + Ok(WholeTestPaths { old, new }) +} + +fn build_counts_for_rust( + context: &BuildContext<'_>, + whole_test_paths: &WholeTestPaths, + change: &FileChange, +) -> Result<(usize, usize), String> { + build_counts( + context, + whole_test_paths.old.get("rs"), + whole_test_paths.new.get("rs"), + change, + rust::split_untracked_source, + rust::split_file_patch, + ) +} + +fn build_counts_for_python( + context: &BuildContext<'_>, + whole_test_paths: &WholeTestPaths, + change: &FileChange, +) -> Result<(usize, usize), String> { + build_counts( + context, + whole_test_paths.old.get("py"), + whole_test_paths.new.get("py"), + change, + python::split_untracked_source, + python::split_file_patch, + ) +} + +fn build_counts_for_javascript( + context: &BuildContext<'_>, + whole_test_paths: &WholeTestPaths, + change: &FileChange, +) -> (usize, usize) { + let Some(language) = change_language(change) else { + return (0, 0); + }; + + select_counts_for_whole_file_only( + change, + whole_test_paths.old.get(language), + whole_test_paths.new.get(language), + context.mode, + ) +} + +fn build_counts_for_mixed_language_change( + context: &BuildContext<'_>, + whole_test_paths: &WholeTestPaths, + change: &FileChange, + old_language: Option<&'static str>, + new_language: Option<&'static str>, +) -> Result<(usize, usize), String> { + let (old_added, old_deleted) = build_side_counts_for_detected_language( + context, + whole_test_paths, + change, + old_language, + ChangeSide::Old, + )?; + let (new_added, new_deleted) = build_side_counts_for_detected_language( + context, + whole_test_paths, + change, + new_language, + ChangeSide::New, + )?; + + Ok((old_added + new_added, old_deleted + new_deleted)) +} + +fn build_side_counts_for_detected_language( + context: &BuildContext<'_>, + whole_test_paths: &WholeTestPaths, + change: &FileChange, + language: Option<&'static str>, + side: ChangeSide, +) -> Result<(usize, usize), String> { + let Some(language) = language else { + return Ok((0, 0)); + }; + + build_side_counts_for_language(context, whole_test_paths, change, language, side) +} + +fn build_counts( + context: &BuildContext<'_>, + old_whole_test_paths: Option<&HashSet>, + new_whole_test_paths: Option<&HashSet>, + change: &FileChange, + split_untracked: UntrackedFn, + split_patch: PatchFn, +) -> Result<(usize, usize), String> +where + Split: TestSplitCounts, + UntrackedFn: Fn(&str) -> Result, + PatchFn: Fn(&crate::patch::FilePatch, &str, &str) -> Result, +{ + let old_is_whole_test = old_whole_test_paths + .map(|paths| paths.contains(&change.old_path)) + .unwrap_or(false); + let new_is_whole_test = new_whole_test_paths + .map(|paths| paths.contains(&change.new_path)) + .unwrap_or(false); + + if old_is_whole_test || new_is_whole_test { + return Ok(select_counts_from_whole_file( + change, + old_is_whole_test, + new_is_whole_test, + context.mode, + )); + } + + if change.untracked { + let source = context.git.read_worktree_file(&change.new_path)?; + let split = split_untracked(&source)?; + return Ok(select_counts_from_split(&split, context.mode)); + } + + let file_patch = context + .patch_map + .get(&change.new_path) + .ok_or_else(|| format!("missing patch data for {}", change.path))?; + let old_source = match context.endpoints { + Some(endpoints) => context + .git + .show_file_at_revision(&endpoints.old, &change.old_path) + .unwrap_or_default(), + None => context + .git + .show_index_file(&change.old_path) + .unwrap_or_default(), + }; + let new_source = match context.endpoints { + Some(endpoints) => context + .git + .show_file_at_revision(&endpoints.new, &change.new_path) + .unwrap_or_default(), + None => context + .git + .read_worktree_file(&change.new_path) + .unwrap_or_default(), + }; + let split = split_patch(file_patch, &old_source, &new_source)?; + Ok(select_counts_from_split(&split, context.mode)) +} + +fn select_counts_for_whole_file_only( + change: &FileChange, + old_whole_test_paths: Option<&HashSet>, + new_whole_test_paths: Option<&HashSet>, + mode: TestFilterMode, +) -> (usize, usize) { + let old_is_whole_test = old_whole_test_paths + .map(|paths| paths.contains(&change.old_path)) + .unwrap_or(false); + let new_is_whole_test = new_whole_test_paths + .map(|paths| paths.contains(&change.new_path)) + .unwrap_or(false); + + select_counts_from_whole_file(change, old_is_whole_test, new_is_whole_test, mode) +} + +#[derive(Clone, Copy)] +enum ChangeSide { + Old, + New, +} + +fn build_side_counts_for_language( + context: &BuildContext<'_>, + whole_test_paths: &WholeTestPaths, + change: &FileChange, + language: &'static str, + side: ChangeSide, +) -> Result<(usize, usize), String> { + if !context.langs.contains(&language) { + return Ok((0, 0)); + } + + match language { + "rs" => build_side_counts( + context, + whole_test_paths, + change, + language, + side, + rust::split_file_patch, + ), + "py" => build_side_counts( + context, + whole_test_paths, + change, + language, + side, + python::split_file_patch, + ), + "js" | "ts" | "jsx" | "tsx" | "cjs" | "mjs" => Ok(select_side_counts_for_whole_file_only( + change, + whole_test_paths.old.get(language), + whole_test_paths.new.get(language), + side, + context.mode, + )), + _ => Ok((0, 0)), + } +} + +fn build_side_counts( + context: &BuildContext<'_>, + whole_test_paths: &WholeTestPaths, + change: &FileChange, + language: &'static str, + side: ChangeSide, + split_patch: PatchFn, +) -> Result<(usize, usize), String> +where + Split: TestSplitCounts, + PatchFn: Fn(&crate::patch::FilePatch, &str, &str) -> Result, +{ + let old_is_whole_test = whole_test_paths + .old + .get(language) + .map(|paths| paths.contains(&change.old_path)) + .unwrap_or(false); + let new_is_whole_test = whole_test_paths + .new + .get(language) + .map(|paths| paths.contains(&change.new_path)) + .unwrap_or(false); + + if old_is_whole_test || new_is_whole_test { + return Ok(select_side_counts_from_whole_file( + change, + old_is_whole_test, + new_is_whole_test, + side, + context.mode, + )); + } + + let file_patch = context + .patch_map + .get(&change.new_path) + .ok_or_else(|| format!("missing patch data for {}", change.path))?; + let old_source = match context.endpoints { + Some(endpoints) => context + .git + .show_file_at_revision(&endpoints.old, &change.old_path) + .unwrap_or_default(), + None => context + .git + .show_index_file(&change.old_path) + .unwrap_or_default(), + }; + let new_source = match context.endpoints { + Some(endpoints) => context + .git + .show_file_at_revision(&endpoints.new, &change.new_path) + .unwrap_or_default(), + None => context + .git + .read_worktree_file(&change.new_path) + .unwrap_or_default(), + }; + let split = match side { + ChangeSide::Old => split_patch(file_patch, &old_source, "")?, + ChangeSide::New => split_patch(file_patch, "", &new_source)?, + }; + + Ok(select_side_counts_from_split(&split, side, context.mode)) +} + +fn select_side_counts_from_whole_file( + change: &FileChange, + old_is_whole_test: bool, + new_is_whole_test: bool, + side: ChangeSide, + mode: TestFilterMode, +) -> (usize, usize) { + match side { + ChangeSide::Old => match mode { + TestFilterMode::TestOnly => (0, if old_is_whole_test { change.deleted } else { 0 }), + TestFilterMode::NonTestOnly => (0, if old_is_whole_test { 0 } else { change.deleted }), + TestFilterMode::All => (0, change.deleted), + }, + ChangeSide::New => match mode { + TestFilterMode::TestOnly => (if new_is_whole_test { change.added } else { 0 }, 0), + TestFilterMode::NonTestOnly => (if new_is_whole_test { 0 } else { change.added }, 0), + TestFilterMode::All => (change.added, 0), + }, + } +} + +fn select_side_counts_for_whole_file_only( + change: &FileChange, + old_whole_test_paths: Option<&HashSet>, + new_whole_test_paths: Option<&HashSet>, + side: ChangeSide, + mode: TestFilterMode, +) -> (usize, usize) { + let old_is_whole_test = old_whole_test_paths + .map(|paths| paths.contains(&change.old_path)) + .unwrap_or(false); + let new_is_whole_test = new_whole_test_paths + .map(|paths| paths.contains(&change.new_path)) + .unwrap_or(false); + + select_side_counts_from_whole_file(change, old_is_whole_test, new_is_whole_test, side, mode) +} + +fn select_side_counts_from_split( + split: &impl TestSplitCounts, + side: ChangeSide, + mode: TestFilterMode, +) -> (usize, usize) { + match side { + ChangeSide::Old => match mode { + TestFilterMode::TestOnly => (0, split.test_deleted()), + TestFilterMode::NonTestOnly => (0, split.non_test_deleted()), + TestFilterMode::All => (0, split.test_deleted() + split.non_test_deleted()), + }, + ChangeSide::New => match mode { + TestFilterMode::TestOnly => (split.test_added(), 0), + TestFilterMode::NonTestOnly => (split.non_test_added(), 0), + TestFilterMode::All => (split.test_added() + split.non_test_added(), 0), + }, + } +} + +trait TestSplitCounts { + fn test_added(&self) -> usize; + fn test_deleted(&self) -> usize; + fn non_test_added(&self) -> usize; + fn non_test_deleted(&self) -> usize; +} + +impl TestSplitCounts for crate::filter::RustTestSplit { + fn test_added(&self) -> usize { + self.test_added + } + + fn test_deleted(&self) -> usize { + self.test_deleted + } + + fn non_test_added(&self) -> usize { + self.non_test_added + } + + fn non_test_deleted(&self) -> usize { + self.non_test_deleted + } +} + +impl TestSplitCounts for crate::python_tests::PythonTestSplit { + fn test_added(&self) -> usize { + self.test_added + } + + fn test_deleted(&self) -> usize { + self.test_deleted + } + + fn non_test_added(&self) -> usize { + self.non_test_added + } + + fn non_test_deleted(&self) -> usize { + self.non_test_deleted + } +} + +fn select_counts_from_whole_file( + change: &FileChange, + old_is_whole_test: bool, + new_is_whole_test: bool, + mode: TestFilterMode, +) -> (usize, usize) { + match mode { + TestFilterMode::TestOnly => ( + if new_is_whole_test { change.added } else { 0 }, + if old_is_whole_test { change.deleted } else { 0 }, + ), + TestFilterMode::NonTestOnly => ( + if new_is_whole_test { 0 } else { change.added }, + if old_is_whole_test { 0 } else { change.deleted }, + ), + TestFilterMode::All => (change.added, change.deleted), + } +} + +fn select_counts_from_split(split: &impl TestSplitCounts, mode: TestFilterMode) -> (usize, usize) { + match mode { + TestFilterMode::TestOnly => (split.test_added(), split.test_deleted()), + TestFilterMode::NonTestOnly => (split.non_test_added(), split.non_test_deleted()), + TestFilterMode::All => ( + split.test_added() + split.non_test_added(), + split.test_deleted() + split.non_test_deleted(), + ), + } +} + +fn change_language(change: &FileChange) -> Option<&'static str> { + detect_language(&change.new_path).or_else(|| detect_language(&change.old_path)) +} + +fn load_index_sources(git: &Git, langs: &[&str]) -> Result, String> { + load_sources(git.tracked_files()?, langs, |path| { + git.show_index_file(path) + }) +} + +fn load_index_paths(git: &Git, langs: &[&str]) -> Result, String> { + Ok(filter_paths(git.tracked_files()?, langs)) +} + +fn load_worktree_sources(git: &Git, langs: &[&str]) -> Result, String> { + let mut paths = git.tracked_files()?; + paths.retain(|path| git.worktree_file_exists(path)); + paths.extend(git.untracked_files()?); + load_sources(paths, langs, |path| git.read_worktree_file(path)) +} + +fn load_worktree_paths(git: &Git, langs: &[&str]) -> Result, String> { + let mut paths = git.tracked_files()?; + paths.retain(|path| git.worktree_file_exists(path)); + paths.extend(git.untracked_files()?); + Ok(filter_paths(paths, langs)) +} + +fn load_revision_sources( + git: &Git, + revision: &str, + langs: &[&str], +) -> Result, String> { + load_sources(git.revision_files(revision)?, langs, |path| { + git.show_file_at_revision(revision, path) + }) +} + +fn load_revision_paths(git: &Git, revision: &str, langs: &[&str]) -> Result, String> { + Ok(filter_paths(git.revision_files(revision)?, langs)) +} + +fn load_sources( + paths: Vec, + langs: &[&str], + mut read_source: F, +) -> Result, String> +where + F: FnMut(&str) -> Result, +{ + let mut sources = Vec::new(); + + for path in filter_paths(paths, langs) { + sources.push((path.clone(), read_source(&path)?)); + } + + Ok(sources) +} + +fn filter_paths(paths: Vec, langs: &[&str]) -> Vec { + paths + .into_iter() + .filter(|path| should_include_path(path, langs)) + .collect() +} + +fn should_include_path(path: &str, langs: &[&str]) -> bool { + let Some(language) = detect_language(path) else { + return false; + }; + + langs.is_empty() || langs.contains(&language) +} + +fn path_entries(paths: &[String]) -> Vec<(String, String)> { + paths + .iter() + .cloned() + .map(|path| (path, String::new())) + .collect() +} diff --git a/tests/cli_args.rs b/tests/cli_args.rs index babf2c6..08f4899 100644 --- a/tests/cli_args.rs +++ b/tests/cli_args.rs @@ -5,7 +5,7 @@ use predicates::prelude::predicate; fn rejects_test_and_no_test_together() { Command::cargo_bin("git-diff-stat") .unwrap() - .args(["--test", "--no-test"]) + .args(["--test", "--non-test"]) .assert() .failure() .stderr(predicate::str::contains("cannot be used with")); @@ -25,12 +25,22 @@ fn rejects_test_and_no_test_filter_together() { fn rejects_no_test_and_no_test_filter_together() { Command::cargo_bin("git-diff-stat") .unwrap() - .args(["--no-test", "--no-test-filter"]) + .args(["--non-test", "--no-test-filter"]) .assert() .failure() .stderr(predicate::str::contains("cannot be used with")); } +#[test] +fn rejects_legacy_no_test_flag() { + Command::cargo_bin("git-diff-stat") + .unwrap() + .arg("--no-test") + .assert() + .failure() + .stderr(predicate::str::contains("unexpected argument '--no-test'")); +} + #[test] fn rejects_last_with_commit() { Command::cargo_bin("git-diff-stat") diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 9c21de5..e610524 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -26,6 +26,11 @@ fn working_tree_output_mentions_scope_lang_and_test_mode() { "pub fn answer() -> i32 {\n 41\n}\n", ) .unwrap(); + fs::write( + tempdir.path().join("web.js"), + "export const answer = () => 41;\n", + ) + .unwrap(); run_git(tempdir.path(), ["add", "src/lib.rs"]); run_git(tempdir.path(), ["commit", "-m", "initial"]); @@ -41,9 +46,10 @@ fn working_tree_output_mentions_scope_lang_and_test_mode() { .assert() .success() .stdout(predicate::str::contains( - "未提交的 rs 文件中,非测试代码统计如下:", + "Non-test code stats for rs,py,js,ts,jsx,tsx,cjs,mjs files in the working tree:", )) - .stdout(predicate::str::contains("src/lib.rs")); + .stdout(predicate::str::contains("src/lib.rs")) + .stdout(predicate::str::contains("web.js")); } #[test] @@ -58,6 +64,11 @@ fn help_mentions_common_examples() { .stdout(predicate::str::contains( "git diff-stat --last --no-test-filter", )) + .stdout(predicate::str::contains("git diff-stat --lang tsx --test")) + .stdout(predicate::str::contains( + "--lang all supported languages (rs,py,js,ts,jsx,tsx,cjs,mjs)", + )) + .stdout(predicate::str::contains("test filter: --non-test")) .stdout(predicate::str::contains("--no-test-filter")); } @@ -90,7 +101,7 @@ fn last_flag_reports_head_patch() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs 文件中,测试与非测试代码统计如下:", + "Test and non-test code stats for rs,py,js,ts,jsx,tsx,cjs,mjs files in the last commit:", )) .stdout(predicate::str::contains("src/tracked.rs")) .stdout(predicate::str::contains("1 insertion")); @@ -152,11 +163,11 @@ fn default_filters_to_rust_non_test_changes() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs 文件中,非测试代码统计如下:", + "Non-test code stats for rs,py,js,ts,jsx,tsx,cjs,mjs files in the last commit:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("tests/integration.rs").not()) - .stdout(predicate::str::contains("web.js").not()); + .stdout(predicate::str::contains("web.js")); } #[test] @@ -215,11 +226,117 @@ fn no_test_filter_includes_all_rust_changes_but_keeps_default_lang() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs 文件中,测试与非测试代码统计如下:", + "Test and non-test code stats for rs,py,js,ts,jsx,tsx,cjs,mjs files in the last commit:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("tests/integration.rs")) - .stdout(predicate::str::contains("web.js").not()); + .stdout(predicate::str::contains("web.js")); +} + +#[test] +fn no_test_filter_does_not_parse_invalid_rust_sources() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::write(tempdir.path().join("src/lib.rs"), [0xff, 0xfe, 0xfd]).unwrap(); + fs::write( + tempdir.path().join("web.js"), + "export const answer = () => 41;\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/lib.rs", "web.js"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("web.js"), + "export const answer = () => 42;\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--no-test-filter"]) + .assert() + .success() + .stdout(predicate::str::contains("web.js")) + .stdout(predicate::str::contains("src/lib.rs").not()); +} + +#[test] +fn default_lang_includes_rust_and_python_non_test_changes() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::create_dir_all(tempdir.path().join("app")).unwrap(); + fs::create_dir_all(tempdir.path().join("tests")).unwrap(); + fs::write( + tempdir.path().join("src/lib.rs"), + "pub fn answer() -> i32 {\n 41\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("app/main.py"), + "def answer() -> int:\n return 41\n\n\ndef test_inline() -> None:\n assert True\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("web.js"), + "export const answer = () => 41;\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("tests/test_app.py"), + "def test_external() -> None:\n assert True\n", + ) + .unwrap(); + run_git( + tempdir.path(), + [ + "add", + "src/lib.rs", + "app/main.py", + "web.js", + "tests/test_app.py", + ], + ); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("src/lib.rs"), + "pub fn answer() -> i32 {\n 42\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("app/main.py"), + "def answer() -> int:\n return 42\n\n\ndef test_inline() -> None:\n assert False\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("web.js"), + "export const answer = () => 42;\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("tests/test_app.py"), + "def test_external() -> None:\n assert False\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .assert() + .success() + .stdout(predicate::str::contains( + "Non-test code stats for rs,py,js,ts,jsx,tsx,cjs,mjs files in the working tree:", + )) + .stdout(predicate::str::contains("src/lib.rs")) + .stdout(predicate::str::contains("app/main.py")) + .stdout(predicate::str::contains("web.js")) + .stdout(predicate::str::contains("tests/test_app.py").not()); } #[test] @@ -261,12 +378,414 @@ fn revision_range_output_mentions_range_langs_and_test_mode() { .assert() .success() .stdout(predicate::str::contains( - "HEAD~1 到 HEAD 的 rs,js 文件中,测试与非测试代码统计如下:", + "Test and non-test code stats for rs,js files from HEAD~1 to HEAD:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("web.js")); } +#[test] +fn explicit_python_lang_uses_python_files() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::create_dir_all(tempdir.path().join("app")).unwrap(); + fs::write( + tempdir.path().join("src/lib.rs"), + "pub fn answer() -> i32 {\n 41\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("app/main.py"), + "def answer() -> int:\n return 41\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/lib.rs", "app/main.py"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("src/lib.rs"), + "pub fn answer() -> i32 {\n 42\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("app/main.py"), + "def answer() -> int:\n return 42\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--lang", "py", "--no-test-filter"]) + .assert() + .success() + .stdout(predicate::str::contains( + "Test and non-test code stats for py files in the working tree:", + )) + .stdout(predicate::str::contains("app/main.py")) + .stdout(predicate::str::contains("src/lib.rs").not()); +} + +#[test] +fn explicit_python_lang_skips_loading_unselected_rust_sources() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::create_dir_all(tempdir.path().join("app")).unwrap(); + fs::write(tempdir.path().join("src/lib.rs"), [0xff, 0xfe, 0xfd]).unwrap(); + fs::write( + tempdir.path().join("app/main.py"), + "def answer() -> int:\n return 41\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/lib.rs", "app/main.py"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("app/main.py"), + "def answer() -> int:\n return 42\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--lang", "py"]) + .assert() + .success() + .stdout(predicate::str::contains("app/main.py")) + .stdout(predicate::str::contains("src/lib.rs").not()); +} + +#[test] +fn python_default_non_test_filter_excludes_test_files() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::create_dir_all(tempdir.path().join("tests")).unwrap(); + fs::write( + tempdir.path().join("src/app.py"), + "def answer() -> int:\n return 41\n\n\ndef test_inline() -> None:\n assert True\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("tests/test_app.py"), + "def test_external() -> None:\n assert True\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/app.py", "tests/test_app.py"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("src/app.py"), + "def answer() -> int:\n return 42\n\n\ndef test_inline() -> None:\n assert False\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("tests/test_app.py"), + "def test_external() -> None:\n assert False\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--lang", "py"]) + .assert() + .success() + .stdout(predicate::str::contains( + "Non-test code stats for py files in the working tree:", + )) + .stdout(predicate::str::contains("src/app.py")) + .stdout(predicate::str::contains("tests/test_app.py").not()); +} + +#[test] +fn python_test_filter_includes_test_files_and_regions() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::create_dir_all(tempdir.path().join("tests")).unwrap(); + fs::write( + tempdir.path().join("src/app.py"), + "def answer() -> int:\n return 41\n\n\ndef test_inline() -> None:\n assert True\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("tests/test_app.py"), + "def test_external() -> None:\n assert True\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/app.py", "tests/test_app.py"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("src/app.py"), + "def answer() -> int:\n return 42\n\n\ndef test_inline() -> None:\n assert False\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("tests/test_app.py"), + "def test_external() -> None:\n assert False\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--lang", "py", "--test"]) + .assert() + .success() + .stdout(predicate::str::contains( + "Test code stats for py files in the working tree:", + )) + .stdout(predicate::str::contains("src/app.py")) + .stdout(predicate::str::contains("tests/test_app.py")); +} + +#[test] +fn mixed_rust_and_python_non_test_filter_handles_both_languages() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::create_dir_all(tempdir.path().join("app")).unwrap(); + fs::write( + tempdir.path().join("src/lib.rs"), + "pub fn answer() -> i32 {\n 41\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("app/main.py"), + "def answer() -> int:\n return 41\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/lib.rs", "app/main.py"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("src/lib.rs"), + "pub fn answer() -> i32 {\n 42\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("app/main.py"), + "def answer() -> int:\n return 42\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--lang", "rs,py"]) + .assert() + .success() + .stdout(predicate::str::contains( + "Non-test code stats for rs,py files in the working tree:", + )) + .stdout(predicate::str::contains("src/lib.rs")) + .stdout(predicate::str::contains("app/main.py")); +} + +#[test] +fn default_non_test_filter_excludes_javascript_and_typescript_test_files() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("web")).unwrap(); + fs::create_dir_all(tempdir.path().join("tests/e2e")).unwrap(); + fs::write( + tempdir.path().join("web/app.tsx"), + "export function App() {\n return
before
;\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("web/app.test.tsx"), + "test('app', () => {\n expect(true).toBe(true);\n});\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("tests/e2e/login.ts"), + "test('login', async () => {\n expect(true).toBe(true);\n});\n", + ) + .unwrap(); + run_git( + tempdir.path(), + [ + "add", + "web/app.tsx", + "web/app.test.tsx", + "tests/e2e/login.ts", + ], + ); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("web/app.tsx"), + "export function App() {\n return
after
;\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("web/app.test.tsx"), + "test('app', () => {\n expect(false).toBe(false);\n});\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("tests/e2e/login.ts"), + "test('login', async () => {\n expect(false).toBe(false);\n});\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .assert() + .success() + .stdout(predicate::str::contains("web/app.tsx")) + .stdout(predicate::str::contains("web/app.test.tsx").not()) + .stdout(predicate::str::contains("tests/e2e/login.ts").not()); +} + +#[test] +fn test_filter_includes_javascript_and_typescript_test_files() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("web")).unwrap(); + fs::create_dir_all(tempdir.path().join("cypress/e2e")).unwrap(); + fs::write( + tempdir.path().join("web/app.tsx"), + "export function App() {\n return
before
;\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("web/app.spec.tsx"), + "test('app', () => {\n expect(true).toBe(true);\n});\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("cypress/e2e/home.cy.js"), + "it('home', () => {\n expect(true).to.eq(true);\n});\n", + ) + .unwrap(); + run_git( + tempdir.path(), + [ + "add", + "web/app.tsx", + "web/app.spec.tsx", + "cypress/e2e/home.cy.js", + ], + ); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("web/app.tsx"), + "export function App() {\n return
after
;\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("web/app.spec.tsx"), + "test('app', () => {\n expect(false).toBe(false);\n});\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("cypress/e2e/home.cy.js"), + "it('home', () => {\n expect(false).to.eq(false);\n});\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .arg("--test") + .assert() + .success() + .stdout(predicate::str::contains("web/app.tsx").not()) + .stdout(predicate::str::contains("web/app.spec.tsx")) + .stdout(predicate::str::contains("cypress/e2e/home.cy.js")); +} + +#[test] +fn no_test_filter_includes_javascript_and_typescript_test_files() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("web")).unwrap(); + fs::create_dir_all(tempdir.path().join("playwright")).unwrap(); + fs::write( + tempdir.path().join("web/app.jsx"), + "export function App() {\n return
before
;\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("playwright/auth.spec.ts"), + "test('auth', async () => {\n expect(true).toBe(true);\n});\n", + ) + .unwrap(); + run_git( + tempdir.path(), + ["add", "web/app.jsx", "playwright/auth.spec.ts"], + ); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("web/app.jsx"), + "export function App() {\n return
after
;\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("playwright/auth.spec.ts"), + "test('auth', async () => {\n expect(false).toBe(false);\n});\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .arg("--no-test-filter") + .assert() + .success() + .stdout(predicate::str::contains("web/app.jsx")) + .stdout(predicate::str::contains("playwright/auth.spec.ts")); +} + +#[test] +fn default_non_test_filter_skips_bulk_reading_frontend_sources_for_path_rules() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("web")).unwrap(); + fs::create_dir_all(tempdir.path().join("scripts")).unwrap(); + fs::write( + tempdir.path().join("web/app.tsx"), + "export function App() {\n return
before
;\n}\n", + ) + .unwrap(); + fs::write(tempdir.path().join("scripts/build.mjs"), [0xff, 0xfe, 0xfd]).unwrap(); + run_git(tempdir.path(), ["add", "web/app.tsx", "scripts/build.mjs"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("web/app.tsx"), + "export function App() {\n return
after
;\n}\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .assert() + .success() + .stdout(predicate::str::contains("web/app.tsx")) + .stdout(predicate::str::contains("scripts/build.mjs").not()); +} + #[test] fn test_filter_counts_rust_integration_test_files_as_test() { let tempdir = tempdir().unwrap(); @@ -325,7 +844,7 @@ fn no_test_filter_excludes_rust_integration_test_files() { Command::cargo_bin("git-diff-stat") .unwrap() .current_dir(tempdir.path()) - .args(["--last", "--lang", "rs", "--no-test"]) + .args(["--last", "--lang", "rs", "--non-test"]) .assert() .success() .stdout(predicate::str::contains("0 files changed")); @@ -405,7 +924,7 @@ fn no_test_filter_excludes_cfg_test_path_module_files() { Command::cargo_bin("git-diff-stat") .unwrap() .current_dir(tempdir.path()) - .args(["--last", "--lang", "rs", "--no-test"]) + .args(["--last", "--lang", "rs", "--non-test"]) .assert() .success() .stdout(predicate::str::contains("0 files changed")); @@ -428,7 +947,7 @@ fn no_test_filter_ignores_zero_line_deleted_rust_files() { Command::cargo_bin("git-diff-stat") .unwrap() .current_dir(tempdir.path()) - .args(["--last", "--lang", "rs", "--no-test"]) + .args(["--last", "--lang", "rs", "--non-test"]) .assert() .success() .stdout(predicate::str::contains("0 files changed")); @@ -464,7 +983,7 @@ fn no_test_filter_handles_renamed_rust_files() { Command::cargo_bin("git-diff-stat") .unwrap() .current_dir(tempdir.path()) - .args(["--last", "--lang", "rs", "--no-test"]) + .args(["--last", "--lang", "rs", "--non-test"]) .assert() .success() .stdout(predicate::str::contains("logging.rs")) @@ -472,6 +991,218 @@ fn no_test_filter_handles_renamed_rust_files() { .stdout(predicate::str::contains("1 deletion")); } +#[test] +fn no_test_filter_keeps_rename_only_entries() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("web/old")).unwrap(); + fs::write( + tempdir.path().join("web/old/app.js"), + "export const answer = () => 41;\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "web/old/app.js"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::create_dir_all(tempdir.path().join("web/new")).unwrap(); + run_git(tempdir.path(), ["mv", "web/old/app.js", "web/new/app.js"]); + run_git(tempdir.path(), ["commit", "-m", "rename js file"]); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--last", "--lang", "js", "--no-test-filter"]) + .assert() + .success() + .stdout(predicate::str::contains("web/{old => new}/app.js")) + .stdout(predicate::str::contains("1 files changed")) + .stdout(predicate::str::contains("0 insertions(+), 0 deletions(-)")); +} + +#[test] +fn test_filter_counts_deleted_python_tests_across_language_rename() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("tests")).unwrap(); + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::write( + tempdir.path().join("tests/test_mod.py"), + "def test_old():\n value = 1\n value = value + 1\n value = value + 1\n value = value + 1\n assert value == 4\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "tests/test_mod.py"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + run_git(tempdir.path(), ["mv", "tests/test_mod.py", "src/lib.rs"]); + fs::write( + tempdir.path().join("src/lib.rs"), + "def test_old():\n value = 1\n value = value + 1\n value = value + 1\n value = value + 2\n assert value == 5\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/lib.rs"]); + run_git( + tempdir.path(), + ["commit", "-m", "rename python test to rust file"], + ); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--last", "--lang", "py,rs", "--test"]) + .assert() + .success() + .stdout(predicate::str::contains("test_mod.py => src/lib.rs")) + .stdout(predicate::str::contains("2 deletions(-)")) + .stdout(predicate::str::contains("0 files changed").not()); +} + +#[test] +fn test_filter_keeps_python_rename_only_entries() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("tests")).unwrap(); + fs::write( + tempdir.path().join("tests/test_app.py"), + "def test_value() -> None:\n assert True\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "tests/test_app.py"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + run_git( + tempdir.path(), + ["mv", "tests/test_app.py", "tests/test_math.py"], + ); + run_git(tempdir.path(), ["commit", "-m", "rename python test"]); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--last", "--lang", "py", "--test"]) + .assert() + .success() + .stdout(predicate::str::contains( + "tests/{test_app.py => test_math.py}", + )) + .stdout(predicate::str::contains("1 files changed")) + .stdout(predicate::str::contains("0 insertions(+), 0 deletions(-)")); +} + +#[test] +fn no_test_filter_splits_cross_language_rename_by_selected_language() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("tests")).unwrap(); + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::write( + tempdir.path().join("tests/test_mod.py"), + "def test_old():\n value = 1\n value = value + 1\n value = value + 1\n value = value + 1\n assert value == 4\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "tests/test_mod.py"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + run_git(tempdir.path(), ["mv", "tests/test_mod.py", "src/lib.rs"]); + fs::write( + tempdir.path().join("src/lib.rs"), + "def test_old():\n value = 1\n value = value + 1\n value = value + 1\n value = value + 2\n assert value == 5\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/lib.rs"]); + run_git( + tempdir.path(), + ["commit", "-m", "rename python test to rust file"], + ); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--last", "--lang", "py", "--no-test-filter"]) + .assert() + .success() + .stdout(predicate::str::contains("test_mod.py => src/lib.rs")) + .stdout(predicate::str::contains("0 insertions(+), 2 deletions(-)")); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--last", "--lang", "rs", "--no-test-filter"]) + .assert() + .success() + .stdout(predicate::str::contains("test_mod.py => src/lib.rs")) + .stdout(predicate::str::contains("2 insertions(+), 0 deletions(-)")); +} + +#[test] +fn non_test_filter_splits_supported_to_unsupported_rename_by_selected_language() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::write( + tempdir.path().join("README.md"), + "pub fn answer() -> i32 {\n 41\n}\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "README.md"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + run_git(tempdir.path(), ["mv", "README.md", "src/lib.rs"]); + fs::write( + tempdir.path().join("src/lib.rs"), + "pub fn answer() -> i32 {\n 42\n}\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/lib.rs"]); + run_git(tempdir.path(), ["commit", "-m", "rename markdown to rust"]); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--last", "--lang", "rs", "--non-test"]) + .assert() + .success() + .stdout(predicate::str::contains("README.md => src/lib.rs")) + .stdout(predicate::str::contains("1 insertions(+), 0 deletions(-)")); +} + +#[test] +fn default_non_test_filter_keeps_javascript_rename_only_entries() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("web")).unwrap(); + fs::write( + tempdir.path().join("web/app.spec.tsx"), + "export const value = 41;\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "web/app.spec.tsx"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + run_git( + tempdir.path(), + ["mv", "web/app.spec.tsx", "web/app.test.tsx"], + ); + run_git(tempdir.path(), ["commit", "-m", "rename tsx test"]); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--last", "--lang", "tsx"]) + .assert() + .success() + .stdout(predicate::str::contains( + "web/{app.spec.tsx => app.test.tsx}", + )) + .stdout(predicate::str::contains("1 files changed")) + .stdout(predicate::str::contains("0 insertions(+), 0 deletions(-)")); +} + fn init_repo(repo: &Path) { run_git(repo, ["init"]); run_git(repo, ["config", "user.name", "Codex"]); diff --git a/tests/javascript_tests.rs b/tests/javascript_tests.rs new file mode 100644 index 0000000..e00af24 --- /dev/null +++ b/tests/javascript_tests.rs @@ -0,0 +1,28 @@ +use git_diff_stat::lang::javascript::collect_whole_test_paths; + +#[test] +fn collects_javascript_and_typescript_test_paths() { + let sources = vec![ + ("src/app.ts".to_string(), String::new()), + ("src/__tests__/app.ts".to_string(), String::new()), + ("web/app.test.tsx".to_string(), String::new()), + ("web/app.spec.jsx".to_string(), String::new()), + ("tests/e2e/login.ts".to_string(), String::new()), + ("cypress/e2e/home.cy.js".to_string(), String::new()), + ("playwright/auth.spec.ts".to_string(), String::new()), + ("playwright.config.ts".to_string(), String::new()), + ("scripts/build.mjs".to_string(), String::new()), + ]; + + let whole_test_paths = collect_whole_test_paths(&sources).unwrap(); + + assert!(whole_test_paths.contains("src/__tests__/app.ts")); + assert!(whole_test_paths.contains("web/app.test.tsx")); + assert!(whole_test_paths.contains("web/app.spec.jsx")); + assert!(whole_test_paths.contains("tests/e2e/login.ts")); + assert!(whole_test_paths.contains("cypress/e2e/home.cy.js")); + assert!(whole_test_paths.contains("playwright/auth.spec.ts")); + assert!(!whole_test_paths.contains("src/app.ts")); + assert!(!whole_test_paths.contains("playwright.config.ts")); + assert!(!whole_test_paths.contains("scripts/build.mjs")); +} diff --git a/tests/lang_filter.rs b/tests/lang_filter.rs index ca908d1..1d41dfd 100644 --- a/tests/lang_filter.rs +++ b/tests/lang_filter.rs @@ -1,5 +1,5 @@ use git_diff_stat::change::FileChange; -use git_diff_stat::lang::filter_by_langs; +use git_diff_stat::lang::{filter_by_langs, parse_langs}; #[test] fn filters_to_requested_extensions() { @@ -27,3 +27,126 @@ fn filters_to_requested_extensions() { assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].path, "src/lib.rs"); } + +#[test] +fn filters_to_python_extension() { + let changes = vec![ + FileChange { + path: "src/lib.rs".to_string(), + old_path: "src/lib.rs".to_string(), + new_path: "src/lib.rs".to_string(), + added: 3, + deleted: 1, + untracked: false, + }, + FileChange { + path: "app/main.py".to_string(), + old_path: "app/main.py".to_string(), + new_path: "app/main.py".to_string(), + added: 5, + deleted: 0, + untracked: false, + }, + ]; + + let filtered = filter_by_langs(&changes, &["py"]).unwrap(); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].path, "app/main.py"); +} + +#[test] +fn omitted_lang_defaults_to_all_supported_languages() { + assert_eq!( + parse_langs(None), + vec!["rs", "py", "js", "ts", "jsx", "tsx", "cjs", "mjs"] + ); +} + +#[test] +fn filters_js_ts_family_extensions_individually() { + let changes = vec![ + FileChange { + path: "web/app.js".to_string(), + old_path: "web/app.js".to_string(), + new_path: "web/app.js".to_string(), + added: 1, + deleted: 0, + untracked: false, + }, + FileChange { + path: "web/app.ts".to_string(), + old_path: "web/app.ts".to_string(), + new_path: "web/app.ts".to_string(), + added: 1, + deleted: 0, + untracked: false, + }, + FileChange { + path: "web/component.jsx".to_string(), + old_path: "web/component.jsx".to_string(), + new_path: "web/component.jsx".to_string(), + added: 1, + deleted: 0, + untracked: false, + }, + FileChange { + path: "web/component.tsx".to_string(), + old_path: "web/component.tsx".to_string(), + new_path: "web/component.tsx".to_string(), + added: 1, + deleted: 0, + untracked: false, + }, + FileChange { + path: "web/config.cjs".to_string(), + old_path: "web/config.cjs".to_string(), + new_path: "web/config.cjs".to_string(), + added: 1, + deleted: 0, + untracked: false, + }, + FileChange { + path: "web/entry.mjs".to_string(), + old_path: "web/entry.mjs".to_string(), + new_path: "web/entry.mjs".to_string(), + added: 1, + deleted: 0, + untracked: false, + }, + ]; + + let jsx = filter_by_langs(&changes, &["jsx"]).unwrap(); + let tsx = filter_by_langs(&changes, &["tsx"]).unwrap(); + let cjs = filter_by_langs(&changes, &["cjs"]).unwrap(); + let mjs = filter_by_langs(&changes, &["mjs"]).unwrap(); + + assert_eq!(jsx.len(), 1); + assert_eq!(jsx[0].path, "web/component.jsx"); + assert_eq!(tsx.len(), 1); + assert_eq!(tsx[0].path, "web/component.tsx"); + assert_eq!(cjs.len(), 1); + assert_eq!(cjs[0].path, "web/config.cjs"); + assert_eq!(mjs.len(), 1); + assert_eq!(mjs[0].path, "web/entry.mjs"); +} + +#[test] +fn keeps_cross_language_renames_for_either_selected_language() { + let changes = vec![FileChange { + path: "tests/test_mod.py => src/lib.rs".to_string(), + old_path: "tests/test_mod.py".to_string(), + new_path: "src/lib.rs".to_string(), + added: 2, + deleted: 2, + untracked: false, + }]; + + let python = filter_by_langs(&changes, &["py"]).unwrap(); + let rust = filter_by_langs(&changes, &["rs"]).unwrap(); + let javascript = filter_by_langs(&changes, &["js"]).unwrap(); + + assert_eq!(python.len(), 1); + assert_eq!(rust.len(), 1); + assert!(javascript.is_empty()); +} diff --git a/tests/python_tests.rs b/tests/python_tests.rs new file mode 100644 index 0000000..edb0188 --- /dev/null +++ b/tests/python_tests.rs @@ -0,0 +1,148 @@ +use git_diff_stat::patch::{FilePatch, LineEvent, LineKind}; +use git_diff_stat::python_tests::{ + collect_python_whole_test_paths, detect_test_regions, split_file_patch_for_python_tests, + split_untracked_python_source, +}; + +const PYTHON_SOURCE: &str = "\ +def build_report(): + return 1 + + +def test_basic(): + assert True + + +class TestApi: + def test_fetch(self): + assert True + + +class Helper: + def build(self): + return 1 +"; + +#[test] +fn identifies_python_test_regions() { + let regions = detect_test_regions(PYTHON_SOURCE).unwrap(); + + assert!(!regions.contains_line(1)); + assert!(!regions.contains_line(2)); + assert!(regions.contains_line(5)); + assert!(regions.contains_line(6)); + assert!(regions.contains_line(9)); + assert!(regions.contains_line(10)); + assert!(regions.contains_line(11)); + assert!(!regions.contains_line(14)); +} + +#[test] +fn ignores_test_named_methods_on_non_test_classes() { + let source = "\ +class Service: + def test_connection(self): + return True + + +class TestApi: + def test_fetch(self): + assert True + + +def test_top_level(): + assert True +"; + + let regions = detect_test_regions(source).unwrap(); + + assert!(!regions.contains_line(1)); + assert!(!regions.contains_line(2)); + assert!(!regions.contains_line(3)); + assert!(regions.contains_line(6)); + assert!(regions.contains_line(7)); + assert!(regions.contains_line(8)); + assert!(regions.contains_line(11)); + assert!(regions.contains_line(12)); +} + +#[test] +fn collects_pytest_style_whole_test_paths() { + let sources = vec![ + ("src/app.py".to_string(), String::new()), + ("tests/test_app.py".to_string(), String::new()), + ("pkg/test_utils.py".to_string(), String::new()), + ("pkg/helpers_test.py".to_string(), String::new()), + ("pkg/conftest.py".to_string(), String::new()), + ]; + + let whole_test_paths = collect_python_whole_test_paths(&sources).unwrap(); + + assert!(whole_test_paths.contains("tests/test_app.py")); + assert!(whole_test_paths.contains("pkg/test_utils.py")); + assert!(whole_test_paths.contains("pkg/helpers_test.py")); + assert!(whole_test_paths.contains("pkg/conftest.py")); + assert!(!whole_test_paths.contains("src/app.py")); +} + +#[test] +fn splits_python_patch_lines_into_test_and_non_test_counts() { + let old_source = "\ +def build_report(): + return 1 + + +def test_basic(): + assert True +"; + let new_source = "\ +def build_report(): + return 2 + + +def test_basic(): + assert False +"; + let patch = FilePatch { + path: "src/report.py".to_string(), + line_events: vec![ + LineEvent { + kind: LineKind::Deleted, + old_line: Some(2), + new_line: None, + }, + LineEvent { + kind: LineKind::Added, + old_line: None, + new_line: Some(2), + }, + LineEvent { + kind: LineKind::Deleted, + old_line: Some(6), + new_line: None, + }, + LineEvent { + kind: LineKind::Added, + old_line: None, + new_line: Some(6), + }, + ], + }; + + let split = split_file_patch_for_python_tests(&patch, old_source, new_source).unwrap(); + + assert_eq!(split.non_test_added, 1); + assert_eq!(split.non_test_deleted, 1); + assert_eq!(split.test_added, 1); + assert_eq!(split.test_deleted, 1); +} + +#[test] +fn splits_untracked_python_source_into_test_and_non_test_counts() { + let split = split_untracked_python_source(PYTHON_SOURCE).unwrap(); + + assert_eq!(split.non_test_added, 11); + assert_eq!(split.test_added, 5); + assert_eq!(split.test_deleted, 0); + assert_eq!(split.non_test_deleted, 0); +} diff --git a/tests/readme_presence.rs b/tests/readme_presence.rs index 4bb1e9d..ae88651 100644 --- a/tests/readme_presence.rs +++ b/tests/readme_presence.rs @@ -4,4 +4,7 @@ fn readme_mentions_github_release_install() { assert!(readme.contains("GitHub Releases")); assert!(readme.contains("v0.1.0")); + assert!(readme.contains("`--lang` defaults to all supported languages")); + assert!(readme.contains("*.test.*")); + assert!(readme.contains("cypress/")); }